diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 932246b..31b7d05 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -6,7 +6,7 @@ plugins { } android { - namespace = "com.zkwl.xueguang.xueguang_flutter_app" + namespace = "com.zkwl.xueguang" compileSdk = flutter.compileSdkVersion ndkVersion = "27.0.12077973" @@ -21,7 +21,7 @@ android { defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId = "com.zkwl.xueguang.xueguang_flutter_app" + applicationId = "com.zkwl.xueguang" // You can update the following values to match your application needs. // For more information, see: https://flutter.dev/to/review-gradle-config. minSdk = flutter.minSdkVersion diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index d43376a..fa2d3e6 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,28 +1,45 @@ + + + + + + + + + + + + + + + + + + + android:icon="@mipmap/ic_launcher" + android:label="学光自习室"> + android:name="io.flutter.embedding.android.NormalTheme" + android:resource="@style/NormalTheme" /> - - + + - - + + diff --git a/android/app/src/main/kotlin/com/zkwl/xueguang/xueguang_flutter_app/MainActivity.kt b/android/app/src/main/kotlin/com/zkwl/xueguang/xueguang_flutter_app/MainActivity.kt index 0a78501..b4082f3 100644 --- a/android/app/src/main/kotlin/com/zkwl/xueguang/xueguang_flutter_app/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zkwl/xueguang/xueguang_flutter_app/MainActivity.kt @@ -1,4 +1,4 @@ -package com.zkwl.xueguang.xueguang_flutter_app +package com.zkwl.xueguang import io.flutter.embedding.android.FlutterActivity diff --git a/assets/image/empty_data.png b/assets/image/empty_data.png new file mode 100644 index 0000000..d328a99 Binary files /dev/null and b/assets/image/empty_data.png differ diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 0000000..fa0b357 --- /dev/null +++ b/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/lib/config/config.dart b/lib/config/config.dart index 9b5a7f5..60ea124 100644 --- a/lib/config/config.dart +++ b/lib/config/config.dart @@ -8,9 +8,17 @@ class Config { ///获取接口地址 static String baseUrl() { if (getEnv() == 'dev') { - return 'https://mindapp.test.tuzuu.com/api'; + return 'https://xueguang.test.tuzuu.com/api'; } else { - return 'https://mindapp.cells.org.cn/api'; + return 'https://xueguang.test.tuzuu.com/api'; } } + + /// 获取websocket地址 + static String wsUrl() { + return "wss://xueguang.test.tuzuu.com/ws"; + } + + ///声网APPid + static String get swAppId => "011c2fd2e1854511a80c1aebded4eee7"; } diff --git a/lib/data/local/storage.dart b/lib/data/local/storage.dart new file mode 100644 index 0000000..f7740fb --- /dev/null +++ b/lib/data/local/storage.dart @@ -0,0 +1,50 @@ +import 'dart:convert'; + +import 'package:shared_preferences/shared_preferences.dart'; + + +class Storage { + + //存储数据 + static Future set(String key, dynamic value) async { + SharedPreferences sp = await SharedPreferences.getInstance(); + if (value is String) { + sp.setString(key, value); + } else if (value is int) { + sp.setInt(key, value); + } else if (value is bool) { + sp.setBool(key, value); + } else if (value is double) { + sp.setDouble(key, value); + } else if (value is Map) { + String jsonStr = jsonEncode(value); + sp.setString(key, jsonStr); + } + } + + //获取数据 + static Future get(String key) async { + SharedPreferences sp = await SharedPreferences.getInstance(); + var value = sp.get(key); + if (value is String) { + try { + return jsonDecode(value); + } catch (e) { + return value; + } + } + return value; + } + + //删除数据 + static Future remove(key) async { + SharedPreferences sp = await SharedPreferences.getInstance(); + sp.remove(key); + } + +//判断键是否存在 + static Future hasKey(String key) async { + SharedPreferences sp = await SharedPreferences.getInstance(); + return sp.containsKey(key); + } +} diff --git a/lib/main.dart b/lib/main.dart index df250e7..63a01e7 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,13 +1,22 @@ +import 'package:app/providers/user_store.dart'; import 'package:flutter/material.dart'; import 'package:flutter_easyloading/flutter_easyloading.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:app/router/routes.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:provider/provider.dart'; import 'config/theme/theme.dart'; void main() { - runApp(const MyApp()); + runApp( + MultiProvider( + providers: [ + ChangeNotifierProvider(create: (_) => UserStore()), + ], + child: const MyApp(), + ), + ); } class MyApp extends StatelessWidget { diff --git a/lib/pages/common/auth/login_page.dart b/lib/pages/common/auth/login_page.dart index a0c1f23..e2e9702 100644 --- a/lib/pages/common/auth/login_page.dart +++ b/lib/pages/common/auth/login_page.dart @@ -1,14 +1,16 @@ import 'dart:async'; +import 'package:app/providers/user_store.dart'; +import 'package:app/request/api/user_api.dart'; import 'package:app/router/route_paths.dart'; import 'package:app/widgets/base/button/index.dart'; import 'package:flutter/material.dart'; import 'package:flutter_easyloading/flutter_easyloading.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:go_router/go_router.dart'; +import 'package:provider/provider.dart'; import 'package:remixicon/remixicon.dart'; -import 'widgets/login_agree.dart'; import 'widgets/login_input.dart'; class LoginPage extends StatefulWidget { @@ -23,8 +25,11 @@ class _LoginPageState extends State { bool _agree = false; ///输入框 - final TextEditingController _telController = TextEditingController(); - final TextEditingController _codeController = TextEditingController(); + final TextEditingController _telController = TextEditingController(text: "13343214321"); + final TextEditingController _codeController = TextEditingController(text: "1111"); + + ///登录中 + bool _loading = false; ///验证码倒计时 var _countDown = 0; @@ -42,6 +47,7 @@ class _LoginPageState extends State { EasyLoading.showToast("请填写正确的手机号"); return; } + sendCodeApi(_telController.text); setState(() { _countDown = 60; }); @@ -63,7 +69,29 @@ class _LoginPageState extends State { EasyLoading.showToast("请填写完整手机号或验证码"); return; } - context.go(RoutePaths.sHome); + try { + setState(() { + _loading = true; + }); + var loginRes = await loginApi(_telController.text, _codeController.text); + if (mounted) { + UserStore userStore = context.read(); + + //设置登录信息l + await userStore.setToken(loginRes.accessToken); + await userStore.asyncUserInfo(); + if (!mounted) return; + if (userStore.userInfo?.accountType == 1) { + context.go(RoutePaths.sHome); + } else { + context.go(RoutePaths.sHome); + } + } + } finally { + setState(() { + _loading = false; + }); + } } @override @@ -130,21 +158,25 @@ class _LoginPageState extends State { Container( margin: EdgeInsets.only(top: 40), height: 50, - child: Button(text: "登 录", onPressed: _handSubmit), - ), - Container( - width: double.infinity, - margin: EdgeInsets.only(top: 20), - alignment: Alignment.center, - child: LoginAgree( - value: _agree, - onChanged: (value) { - setState(() { - _agree = value!; - }); - }, + child: Button( + text: "登 录", + loading: _loading, + onPressed: _handSubmit, ), ), + // Container( + // width: double.infinity, + // margin: EdgeInsets.only(top: 20), + // alignment: Alignment.center, + // child: LoginAgree( + // value: _agree, + // onChanged: (value) { + // setState(() { + // _agree = value!; + // }); + // }, + // ), + // ), ], ), ), diff --git a/lib/pages/common/splash/splash_page.dart b/lib/pages/common/splash/splash_page.dart index 10fc100..fc7c125 100644 --- a/lib/pages/common/splash/splash_page.dart +++ b/lib/pages/common/splash/splash_page.dart @@ -1,6 +1,8 @@ +import 'package:app/providers/user_store.dart'; import 'package:app/router/route_paths.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import 'package:provider/provider.dart'; class SplashPage extends StatefulWidget { const SplashPage({super.key}); @@ -19,7 +21,24 @@ class _SplashPageState extends State { ///权限效验初始化 void initPermission() async { WidgetsBinding.instance.addPostFrameCallback((_) async { - context.go(RoutePaths.login); + String token = await UserStore.getToken(); + if (mounted) { + // 未登录 + if (token.isEmpty) { + context.go(RoutePaths.login); + } else { + UserStore userStore = context.read(); + userStore.setUserInfo(); + //去学生主页 + if (userStore.userInfo?.accountType == 1) { + context.go(RoutePaths.sHome); + } else { + context.go(RoutePaths.tHome); + } + print("执行用户数据同步了"); + userStore.asyncUserInfo(); + } + } }); } diff --git a/lib/pages/student/home/s_home_page.dart b/lib/pages/student/home/s_home_page.dart index 822bf4b..44002e9 100644 --- a/lib/pages/student/home/s_home_page.dart +++ b/lib/pages/student/home/s_home_page.dart @@ -1,4 +1,6 @@ import 'package:app/config/theme/base/app_theme_ext.dart'; +import 'package:app/request/api/room_api.dart'; +import 'package:app/request/dto/room/room_type_dto.dart'; import 'package:flutter/material.dart'; import 'today/s_today_card.dart'; @@ -12,6 +14,8 @@ class SHomePage extends StatefulWidget { } class _SHomePageState extends State { + + ///刷新状态 Future _refresh() async { await Future.delayed(Duration(seconds: 1)); diff --git a/lib/pages/student/home/today/s_today_card.dart b/lib/pages/student/home/today/s_today_card.dart index c8f8b94..86613bf 100644 --- a/lib/pages/student/home/today/s_today_card.dart +++ b/lib/pages/student/home/today/s_today_card.dart @@ -16,7 +16,6 @@ class STodayCard extends StatefulWidget { } class _STodayCardState extends State { - ///进入自习室 void _handleEnterRoom() { context.push(RoutePaths.sRoom); diff --git a/lib/pages/student/home/widgets/user_header.dart b/lib/pages/student/home/widgets/user_header.dart index 9ac7159..6543b4f 100644 --- a/lib/pages/student/home/widgets/user_header.dart +++ b/lib/pages/student/home/widgets/user_header.dart @@ -1,4 +1,8 @@ +import 'package:app/providers/user_store.dart'; +import 'package:app/router/route_paths.dart'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:provider/provider.dart'; import 'package:remixicon/remixicon.dart'; class UserHeader extends StatelessWidget implements PreferredSizeWidget { @@ -34,7 +38,28 @@ class UserHeader extends StatelessWidget implements PreferredSizeWidget { ], ), ), - const SizedBox(width: 15), + PopupMenuButton( + color: Colors.white, + padding: EdgeInsets.zero, + position: PopupMenuPosition.under, + onSelected: (value) { + if (value == 1) { + UserStore userStore = context.read(); + userStore.logout(); + context.go(RoutePaths.login); + } + }, + itemBuilder: (context) => [ + PopupMenuItem( + value: 1, + child: Text("退出登录", textAlign: TextAlign.center), + ), + ], + child: IconButton( + onPressed: null, + icon: Icon(RemixIcons.user_line), + ), + ), ], ); } diff --git a/lib/pages/teacher/home/t_home_page.dart b/lib/pages/teacher/home/t_home_page.dart index 0fef0cc..8fbeb49 100644 --- a/lib/pages/teacher/home/t_home_page.dart +++ b/lib/pages/teacher/home/t_home_page.dart @@ -1,27 +1,43 @@ import 'package:app/config/theme/base/app_theme_ext.dart'; import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'viewmodel/home_view_model.dart'; import 'widgets/header.dart'; import 'widgets/today_card.dart'; -class THomePage extends StatefulWidget { +class THomePage extends StatelessWidget { const THomePage({super.key}); @override - State createState() => _THomePageState(); + Widget build(BuildContext context) { + return ChangeNotifierProvider( + create: (_) => HomeViewModel(), + child: const _HomeView(), + ); + } } -class _THomePageState extends State { +class _HomeView extends StatelessWidget { + const _HomeView(); + @override Widget build(BuildContext context) { + final vm = context.read(); return Scaffold( backgroundColor: Theme.of(context).colorScheme.surfaceContainer, appBar: Header(), - body: ListView( - padding: EdgeInsets.symmetric(vertical: 20, horizontal: context.pagePadding), - children: [ - TodayCard(), - ], + body: RefreshIndicator( + onRefresh: vm.loadData, + child: ListView( + padding: EdgeInsets.symmetric( + vertical: 20, + horizontal: context.pagePadding, + ), + children: [ + TodayCard(), + ], + ), ), ); } diff --git a/lib/pages/teacher/home/viewmodel/home_view_model.dart b/lib/pages/teacher/home/viewmodel/home_view_model.dart new file mode 100644 index 0000000..1f06b5d --- /dev/null +++ b/lib/pages/teacher/home/viewmodel/home_view_model.dart @@ -0,0 +1,56 @@ +import 'package:app/request/api/room_api.dart'; +import 'package:app/request/dto/room/room_info_dto.dart'; +import 'package:app/utils/time.dart'; +import 'package:flutter/material.dart'; + +class HomeViewModel extends ChangeNotifier { + RoomInfoDto? roomInfo; + bool loading = true; + + HomeViewModel() { + loadData(); + } + + //加载数据 + Future loadData() async { + final list = await getRoomListApi(); + loading = false; + + if (list.isNotEmpty) { + roomInfo = list.first; + } + + notifyListeners(); + } + + ///计算会议时间 + int get roomMinutes { + if (roomInfo == null) return 0; + + final start = roomInfo!.startTime; + final end = roomInfo!.endTime; + + final s = DateTime.parse('2000-01-01 $start:00'); + final e = DateTime.parse('2000-01-01 $end:00'); + + return e.difference(s).inMinutes; + } + + ///能否进入房间 + bool get canEnterRoom { + final info = roomInfo; + if (info == null) return false; + + final now = DateTime.now(); + + //开始时间 + final startTime = parseTime(info.startTime); + + // 当前时间距离开始时间是否超过 5 分钟 + if (now.isBefore(startTime) && startTime.difference(now).inMinutes > 5) { + return false; + } + + return true; + } +} diff --git a/lib/pages/teacher/home/widgets/header.dart b/lib/pages/teacher/home/widgets/header.dart index 1013b77..1d19764 100644 --- a/lib/pages/teacher/home/widgets/header.dart +++ b/lib/pages/teacher/home/widgets/header.dart @@ -1,5 +1,9 @@ import 'package:app/config/theme/base/app_theme_ext.dart'; +import 'package:app/providers/user_store.dart'; +import 'package:app/router/route_paths.dart'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:provider/provider.dart'; import 'package:remixicon/remixicon.dart'; class Header extends StatelessWidget implements PreferredSizeWidget { @@ -18,6 +22,7 @@ class Header extends StatelessWidget implements PreferredSizeWidget { children: [ Column( crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, children: [ Text( "学光自习室", @@ -31,9 +36,27 @@ class Header extends StatelessWidget implements PreferredSizeWidget { ), Row( children: [ - IconButton( - onPressed: () {}, - icon: Icon(RemixIcons.user_line), + PopupMenuButton( + color: Colors.white, + padding: EdgeInsets.zero, + position: PopupMenuPosition.under, + onSelected: (value) { + if (value == 1) { + UserStore userStore = context.read(); + userStore.logout(); + context.go(RoutePaths.login); + } + }, + itemBuilder: (context) => [ + PopupMenuItem( + value: 1, + child: Text("退出登录", textAlign: TextAlign.center), + ), + ], + child: IconButton( + onPressed: null, + icon: Icon(RemixIcons.user_line), + ), ), ], ), diff --git a/lib/pages/teacher/home/widgets/today_card.dart b/lib/pages/teacher/home/widgets/today_card.dart index 0edff38..8cf15ff 100644 --- a/lib/pages/teacher/home/widgets/today_card.dart +++ b/lib/pages/teacher/home/widgets/today_card.dart @@ -1,127 +1,174 @@ +import 'package:app/pages/teacher/home/viewmodel/home_view_model.dart'; +import 'package:app/request/dto/room/room_info_dto.dart'; import 'package:app/router/route_paths.dart'; +import 'package:app/utils/permission.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/tag/index.dart'; +import 'package:app/widgets/base/empty/index.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_easyloading/flutter_easyloading.dart'; import 'package:go_router/go_router.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:provider/provider.dart'; import 'package:remixicon/remixicon.dart'; +import 'package:skeletonizer/skeletonizer.dart'; -class TodayCard extends StatelessWidget { +class TodayCard extends StatefulWidget { const TodayCard({super.key}); + @override + State createState() => _TodayCardState(); +} + +class _TodayCardState extends State { + ///前往会议室 + void _goToRoom() { + checkPermission( + permissions: [Permission.microphone, Permission.camera], + onGranted: () { + final vm = context.read(); + context.push( + RoutePaths.tRoom, + extra: { + "roomId": vm.roomInfo!.id, + "startTime": vm.roomInfo!.startTime, + }, + ); + }, + onDenied: () { + EasyLoading.showError("请开启权限"); + }, + onPermanentlyDenied: () { + EasyLoading.showError("请手动开启麦克风和摄像头权限"); + }, + ); + } + @override Widget build(BuildContext context) { - /// item - Widget item({ - required String title, - required String value, - required IconData icon, - required Color color, - }) { - return Expanded( - child: Container( - padding: EdgeInsets.all(10), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(10), - ), - child: Row( - spacing: 10, - children: [ - Container( - width: 45, - height: 45, - decoration: BoxDecoration( - color: color, - borderRadius: BorderRadius.circular(10), - ), - child: Icon( - icon, - color: Colors.white, - ), - ), - Column( + return Consumer( + builder: (context, vm, _) { + return GCard( + child: Visibility( + visible: !vm.loading && vm.roomInfo == null, + replacement: Skeletonizer( + enabled: vm.loading, + child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(title, style: Theme.of(context).textTheme.labelLarge), - Text(value), - ], - ), - ], - ), - ), - ); - } - - return GCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - spacing: 10, + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + spacing: 10, + children: [ + Skeleton.replace( + replacement: Bone.text(), + child: Text(vm.roomInfo?.roomName ?? ""), + ), + ], + ), + Container( + margin: EdgeInsets.only(top: 5), + child: Text( + "和学生们一起专注学习、共同进步", + style: Theme.of(context).textTheme.labelMedium, + ), + ), + ], + ), + Container( + margin: EdgeInsets.only(top: 30), + child: Row( + spacing: 15, children: [ - Text("高三数学冲刺班"), - Tag(text: "待开始"), + _item( + title: "开始时间", + value: vm.roomInfo?.startTime ?? "", + icon: RemixIcons.time_line, + color: Color(0xff2b7efd), + ), + _item( + title: "结束时间", + value: vm.roomInfo?.endTime ?? "", + icon: RemixIcons.group_line, + color: Color(0xff00c74f), + ), + _item( + title: "时长", + value: "${vm.roomMinutes} 分钟", + icon: RemixIcons.book_open_line, + color: Color(0xffac45fd), + ), ], ), - Container( - margin: EdgeInsets.only(top: 5), - child: Text( - "和学生们一起专注学习、共同进步", - style: Theme.of(context).textTheme.labelMedium, - ), + ), + Container( + margin: EdgeInsets.only(top: 30), + height: 45, + child: Button( + text: vm.canEnterRoom ? "开始自习室" : "未到开始时间", + type: ThemeType.success, + // disabled: !vm.canEnterRoom, + onPressed: _goToRoom, ), - ], - ), + ), + ], ), - Icon(RemixIcons.arrow_right_s_line, size: 30), - ], + ), + child: SizedBox( + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Empty(text: "未分配自习室"), + ], + ), + ), ), - Container( - margin: EdgeInsets.only(top: 30), - child: Row( - spacing: 15, + ); + }, + ); + } + + Widget _item({ + required String title, + required String value, + required IconData icon, + required Color color, + }) { + return Expanded( + child: Container( + padding: EdgeInsets.all(10), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(10), + ), + child: Row( + spacing: 10, + children: [ + Container( + width: 45, + height: 45, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + icon, + color: Colors.white, + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - item( - title: "开始时间", - value: "14:00", - icon: RemixIcons.time_line, - color: Color(0xff2b7efd), - ), - item( - title: "学生人数", - value: "8 名", - icon: RemixIcons.group_line, - color: Color(0xff00c74f), - ), - item( - title: "时长", - value: "120 分钟", - icon: RemixIcons.book_open_line, - color: Color(0xffac45fd), - ), + Text(title, style: Theme.of(context).textTheme.labelLarge), + Text(value), ], ), - ), - Container( - margin: EdgeInsets.only(top: 30), - height: 45, - child: Button( - text: "开始自习室", - type: ThemeType.success, - onPressed: () { - context.push(RoutePaths.tRoom); - }, - ), - ), - ], + ], + ), ), ); } diff --git a/lib/pages/teacher/room/t_room_page.dart b/lib/pages/teacher/room/t_room_page.dart index 2aae604..ee5e23d 100644 --- a/lib/pages/teacher/room/t_room_page.dart +++ b/lib/pages/teacher/room/t_room_page.dart @@ -1,12 +1,18 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'controls/top_bar.dart'; -import 'view/student_item.dart'; -import 'view/waiting_start.dart'; +import 'widgets/status_view.dart'; import 'viewmodel/students_view_model.dart'; class TRoomPage extends StatefulWidget { - const TRoomPage({super.key}); + final int roomId; + final String startTime; + + const TRoomPage({ + super.key, + required this.roomId, + required this.startTime, + }); @override State createState() => _TRoomPageState(); @@ -17,37 +23,15 @@ class _TRoomPageState extends State { Widget build(BuildContext context) { return ChangeNotifierProvider( create: (BuildContext context) { - return StudentsViewModel(); + return StudentsViewModel( + roomId: widget.roomId, + start: widget.startTime, + ); }, child: Scaffold( backgroundColor: Color(0xff2c3032), appBar: TopBar(), - body: true - ? WaitingStart() - : Padding( - padding: const EdgeInsets.all(10), - child: Row( - spacing: 15, - children: [ - Expanded( - child: StudentItem(), - ), - SizedBox( - width: 300, - child: ListView.separated( - itemBuilder: (_, index) { - return SizedBox( - height: 250, - child: StudentItem(), - ); - }, - separatorBuilder: (_, __) => SizedBox(height: 15), - itemCount: 7, - ), - ), - ], - ), - ), + body: StatusView(), ), ); } diff --git a/lib/pages/teacher/room/view/waiting_start.dart b/lib/pages/teacher/room/view/waiting_start.dart deleted file mode 100644 index 856292d..0000000 --- a/lib/pages/teacher/room/view/waiting_start.dart +++ /dev/null @@ -1,80 +0,0 @@ -import 'dart:async'; - -import 'package:app/utils/time.dart'; -import 'package:flutter/material.dart'; - -class WaitingStart extends StatefulWidget { - const WaitingStart({super.key}); - - @override - State createState() => _WaitingStartState(); -} - -class _WaitingStartState extends State { - ///剩余秒 - int _seconds = 0; - Timer? _timer; - - @override - void initState() { - super.initState(); - startCountDown(); - } - - @override - void dispose() { - super.dispose(); - _timer?.cancel(); - _timer = null; - } - - ///开始倒计时 - void startCountDown() { - //当前时间 - DateTime now = DateTime.now(); - //远端时间 - DateTime remote = DateTime.parse("2025-11-19 17:10:00".replaceFirst(' ', 'T')); - setState(() { - _seconds = remote.difference(now).inSeconds; - }); - _timer = Timer.periodic(Duration(seconds: 1), (timer) { - setState(() { - _seconds--; - }); - if (_seconds <= 0) { - _timer?.cancel(); - _timer = null; - _start(); - } - }); - } - - ///倒计时结束开始 - void _start() {} - - @override - Widget build(BuildContext context) { - return Align( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - "未到开播时间,到点后自动开播", - style: TextStyle(color: Colors.white), - ), - Container( - margin: EdgeInsets.symmetric(vertical: 10), - child: Text( - formatSeconds(_seconds), - style: TextStyle( - color: Colors.white, - fontSize: 26, - fontWeight: FontWeight.bold, - ), - ), - ), - ], - ), - ); - } -} diff --git a/lib/pages/teacher/room/viewmodel/students_view_model.dart b/lib/pages/teacher/room/viewmodel/students_view_model.dart index f213b6e..2dd3a05 100644 --- a/lib/pages/teacher/room/viewmodel/students_view_model.dart +++ b/lib/pages/teacher/room/viewmodel/students_view_model.dart @@ -1,33 +1,78 @@ import 'package:app/data/models/student.dart'; -import 'package:app/websocket/room_websocket.dart'; +import 'package:app/request/dto/room/room_user_dto.dart'; +import 'package:app/request/websocket/room_protocol.dart'; +import 'package:app/request/websocket/room_websocket.dart'; +import 'package:app/utils/time.dart'; import 'package:flutter/cupertino.dart'; class StudentsViewModel extends ChangeNotifier { ///学生摄像头列表 List _students = []; + ///房间的基础信息,房间id、房间开始时间 + final int roomId; + late final DateTime startTime; + + StudentsViewModel({required this.roomId, String? start}) { + startTime = parseTime(start!); + _startRoom(); + } + List get students => _students; + ///是否能开始自习室 + bool get canEnterRoom { + final now = DateTime.now(); + + // 如果到了开始时间,则可以进入房间 + if (now.isAfter(startTime)) { + return true; + } + return false; + } + ///websocket管理 late RoomWebSocket _ws; - StudentsViewModel() { - _startRoom(); - } - ///开始链接房间 - void _startRoom() { + void _startRoom() async { _ws = RoomWebSocket(); - _ws.connect(); + //如果socket的token没有,先初始化 + if (_ws.wsToken.isEmpty) { + await _ws.initToken(roomId); + } + //启动连接 + await _ws.connect(); + //进入房间命令 + _ws.send(RoomCommand.joinRoom); + //监听各种ws事件 _ws.stream.listen((msg) { - _handleMessage(); + if (msg.event == RoomEvent.changeUser) { + final list = msg.data['user_list'].map((x) => RoomUserDto.fromJson(x)).toList(); + onStudentChange(list); + } }); notifyListeners(); } - ///发送命令 - void _handleMessage() { - print("监听webscoket传来的事件"); + ///自习室的开关 + /// - [isOpen]: 是否开启 + void toggleRoom({required bool isOpen}) { + if (isOpen) { + _ws.send(RoomCommand.openRoom); + } else { + _ws.send(RoomCommand.closeRoom); + } + } + + ///学生人员变化事件,(如加入、退出、掉线) + void onStudentChange(List list) {} + + //销毁 + @override + void dispose() { + super.dispose(); + _ws.dispose(); } } diff --git a/lib/pages/teacher/room/widgets/content_view.dart b/lib/pages/teacher/room/widgets/content_view.dart new file mode 100644 index 0000000..aefca41 --- /dev/null +++ b/lib/pages/teacher/room/widgets/content_view.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; + +import 'student_item.dart'; + +class ContentView extends StatelessWidget { + const ContentView({super.key}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(10), + child: Row( + spacing: 15, + children: [ + Expanded( + child: StudentItem(), + ), + SizedBox( + width: 300, + child: ListView.separated( + itemBuilder: (_, index) { + return SizedBox( + height: 250, + child: StudentItem(), + ); + }, + separatorBuilder: (_, __) => SizedBox(height: 15), + itemCount: 7, + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages/teacher/room/widgets/status_view.dart b/lib/pages/teacher/room/widgets/status_view.dart new file mode 100644 index 0000000..05b64b7 --- /dev/null +++ b/lib/pages/teacher/room/widgets/status_view.dart @@ -0,0 +1,114 @@ +import 'dart:async'; + +import 'package:app/utils/time.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import 'content_view.dart'; +import '../viewmodel/students_view_model.dart'; + +class StatusView extends StatefulWidget { + const StatusView({super.key}); + + @override + State createState() => _StatusViewState(); +} + +class _StatusViewState extends State { + ///房间状态 + RoomStatus status = RoomStatus.loading; + + ///剩余秒 + int _seconds = 0; + Timer? _timer; + + @override + void initState() { + super.initState(); + _init(); + } + + @override + void dispose() { + super.dispose(); + _timer?.cancel(); + _timer = null; + } + + void _init() { + final vm = context.read(); + //如果房间可以开始 + if (vm.canEnterRoom) { + status = RoomStatus.start; + } else { + status = RoomStatus.waiting; + startCountDown(); + } + } + + ///开始倒计时 + void startCountDown() { + final vm = context.read(); + //当前时间 + DateTime now = DateTime.now(); + //远端时间 + setState(() { + _seconds = vm.startTime.difference(now).inSeconds; + }); + _timer = Timer.periodic(Duration(seconds: 1), (timer) { + setState(() { + _seconds--; + }); + if (_seconds <= 0) { + _timer?.cancel(); + _timer = null; + setState(() { + status = RoomStatus.start; + }); + } + }); + } + + ///开启自习室 + void openRoom() { + final vm = context.read(); + vm.toggleRoom(isOpen: true); + } + + @override + Widget build(BuildContext context) { + if (status == RoomStatus.waiting) { + return Align( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + "未到开播时间,到点后自动开播", + style: TextStyle(color: Colors.white), + ), + Container( + margin: EdgeInsets.symmetric(vertical: 10), + child: Text( + formatSeconds(_seconds), + style: TextStyle( + color: Colors.white, + fontSize: 26, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ); + } else if (status == RoomStatus.start) { + return ContentView(); + } + return SizedBox(); + } +} + +enum RoomStatus { + loading, // 加载中 + waiting, //房间倒计时等待中 + start, //房间开始中 +} diff --git a/lib/pages/teacher/room/view/student_item.dart b/lib/pages/teacher/room/widgets/student_item.dart similarity index 100% rename from lib/pages/teacher/room/view/student_item.dart rename to lib/pages/teacher/room/widgets/student_item.dart diff --git a/lib/providers/user_store.dart b/lib/providers/user_store.dart index e69de29..881ebe9 100644 --- a/lib/providers/user_store.dart +++ b/lib/providers/user_store.dart @@ -0,0 +1,47 @@ +import 'package:app/data/local/storage.dart'; +import 'package:app/request/api/user_api.dart'; +import 'package:app/request/dto/user/user_info_dto.dart'; +import 'package:flutter/cupertino.dart'; + +class UserStore extends ChangeNotifier { + UserInfoDto? userInfo; + String token = ""; + + ///设置用户数据 + Future asyncUserInfo() async { + if (token.isNotEmpty) { + var res = await getUserInfoApi(); + await Storage.set("user_info", res.toJson()); + setUserInfo(); + notifyListeners(); + } + } + + ///获取用户数据 + Future setUserInfo() async { + var info = await Storage.get("user_info"); + if (info != null) { + userInfo = UserInfoDto.fromJson(info); + } + } + + ///设置token + Future setToken(String value) async { + token = value; + await Storage.set('token', token); + } + + ///获取token + static Future getToken() async { + return await Storage.get("token") ?? ''; + } + + ///退出登录 + Future logout() async { + logoutApi(); + await Storage.remove('token'); + await Storage.remove('user_info'); + token = ''; + notifyListeners(); + } +} diff --git a/lib/request/api/room_api.dart b/lib/request/api/room_api.dart new file mode 100644 index 0000000..46dad16 --- /dev/null +++ b/lib/request/api/room_api.dart @@ -0,0 +1,26 @@ +import 'package:app/request/dto/room/rtc_token_dto.dart'; +import 'package:app/request/network/request.dart'; + +import '../dto/room/room_info_dto.dart'; + +/// 获取房间列表 +Future> getRoomListApi() async { + var res = await Request().get('/study_room/get_study_room_list'); + return List.from(res.map((x) => RoomInfoDto.fromJson(x))); +} + +///获取自习室的websocket令牌 +Future getWsTokenApi(int roomId) async { + var res = await Request().get('/study_room/get_ws_token', { + "study_room_id": roomId, + }); + return res['token']; +} + +///获取自习室的RTC令牌 +Future getRtcTokenApi(int roomId) async { + var res = await Request().get('/study_room/get_rtc_token', { + "study_room_id": roomId, + }); + return RtcTokenDto.fromJson(res); +} diff --git a/lib/request/api/user_api.dart b/lib/request/api/user_api.dart new file mode 100644 index 0000000..f3988e1 --- /dev/null +++ b/lib/request/api/user_api.dart @@ -0,0 +1,29 @@ +import 'package:app/request/dto/user/user_info_dto.dart'; +import 'package:app/request/network/request.dart'; + +import '../dto/user/login_dto.dart'; + +///发送验证码 +Future sendCodeApi(String tel) async { + await Request().get("/send_sms_code", {"tel": tel}); +} + +///登录 +Future loginApi(String tel, String code) async { + var res = await Request().post("/login", { + "tel": tel, + "sms_code": code, + }); + return LoginDto.fromJson(res); +} + +/// 获取用户信息 +Future getUserInfoApi() async { + var response = await Request().get("/get_my_info"); + return UserInfoDto.fromJson(response); +} + +///退出登录 +Future logoutApi() async { + await Request().get("/logout"); +} \ No newline at end of file diff --git a/lib/request/dto/room/room_file_dto.dart b/lib/request/dto/room/room_file_dto.dart new file mode 100644 index 0000000..3f4d5b2 --- /dev/null +++ b/lib/request/dto/room/room_file_dto.dart @@ -0,0 +1,24 @@ +class RoomFileDto { + RoomFileDto({ + this.fileName = "", + this.fileSize = 0, + this.fileUrl = "", + }); + + RoomFileDto.fromJson(Map json) + : fileName = json['file_name'] ?? "", + fileSize = json['file_size'] ?? 0, + fileUrl = json['file_url'] ?? ""; + + String fileName; + int fileSize; + String fileUrl; + + Map toJson() { + final map = {}; + map['file_name'] = fileName; + map['file_size'] = fileSize; + map['file_url'] = fileUrl; + return map; + } +} diff --git a/lib/request/dto/room/room_info_dto.dart b/lib/request/dto/room/room_info_dto.dart new file mode 100644 index 0000000..8866bfd --- /dev/null +++ b/lib/request/dto/room/room_info_dto.dart @@ -0,0 +1,39 @@ +class RoomInfoDto { + + + RoomInfoDto({ + required this.teacherBackground, + required this.roomName, + required this.startTime, + required this.teacherName, + required this.endTime, + required this.id, + }); + + String teacherBackground; + String roomName; + String startTime; + String teacherName; + String endTime; + int id; + + factory RoomInfoDto.fromJson(Map json) => + RoomInfoDto( + teacherBackground: json["teacher_background"], + roomName: json["room_name"], + startTime: json["start_time"], + teacherName: json["teacher_name"], + endTime: json["end_time"], + id: json["id"], + ); + + Map toJson() => + { + "teacher_background": teacherBackground, + "room_name": roomName, + "start_time": startTime, + "teacher_name": teacherName, + "end_time": endTime, + "id": id, + }; +} diff --git a/lib/request/dto/room/room_type_dto.dart b/lib/request/dto/room/room_type_dto.dart new file mode 100644 index 0000000..381cd18 --- /dev/null +++ b/lib/request/dto/room/room_type_dto.dart @@ -0,0 +1,39 @@ +class RoomTypeDto { + final int studyRoomId; + final int teacherId; + final String 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"] ?? "", + 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 new file mode 100644 index 0000000..db221b1 --- /dev/null +++ b/lib/request/dto/room/room_user_dto.dart @@ -0,0 +1,67 @@ +class RoomUserDto { + final int userId; + final String rtcUid; + final int microphoneStatus; + final int cameraStatus; + final int speekerStatus; + final String wsClientId; + final String userName; + final String avatar; + final int userType; + final List filesList; + final String dataType; + final int handup; + final int online; //0离线,1在线 + + const RoomUserDto({ + required this.userId, + required this.rtcUid, + required this.microphoneStatus, + required this.cameraStatus, + required this.speekerStatus, + required this.wsClientId, + required this.userName, + required this.avatar, + required this.userType, + required this.filesList, + required this.dataType, + required this.handup, + required this.online, + }); + + factory RoomUserDto.fromJson(Map json) { + return RoomUserDto( + userId: json["user_id"] ?? 0, + rtcUid: json["rtc_uid"] ?? "", + microphoneStatus: json["microphone_status"] ?? 0, + cameraStatus: json["camera_status"] ?? 0, + speekerStatus: json["speeker_status"] ?? 0, + wsClientId: json["ws_client_id"] ?? "", + userName: json["user_name"] ?? "", + avatar: json["avatar"] ?? "", + userType: json["user_type"] ?? 0, + filesList: json["files"] != null ? List.from(json["files"]) : [], + dataType: json["data_type"] ?? "", + handup: json["handup"] ?? 0, + online: json["online"] ?? 0, + ); + } + + Map toJson() { + return { + "user_id": userId, + "rtc_uid": rtcUid, + "microphone_status": microphoneStatus, + "camera_status": cameraStatus, + "speeker_status": speekerStatus, + "ws_client_id": wsClientId, + "user_name": userName, + "avatar": avatar, + "user_type": userType, + "files": filesList, + "data_type": dataType, + "handup": handup, + "online": online, + }; + } +} diff --git a/lib/request/dto/room/rtc_token_dto.dart b/lib/request/dto/room/rtc_token_dto.dart new file mode 100644 index 0000000..ee5dfcf --- /dev/null +++ b/lib/request/dto/room/rtc_token_dto.dart @@ -0,0 +1,27 @@ +class RtcTokenDto { + RtcTokenDto({ + required this.uid, + required this.expiresAt, + required this.channel, + required this.token, + }); + + String uid; + DateTime expiresAt; + String channel; + String token; + + factory RtcTokenDto.fromJson(Map json) => RtcTokenDto( + uid: json["uid"], + expiresAt: DateTime.parse(json["expires_at"]), + channel: json["channel"], + token: json["token"], + ); + + Map toJson() => { + "uid": uid, + "expires_at": expiresAt.toIso8601String(), + "channel": channel, + "token": token, + }; +} diff --git a/lib/request/dto/user/login_dto.dart b/lib/request/dto/user/login_dto.dart new file mode 100644 index 0000000..b6d37ef --- /dev/null +++ b/lib/request/dto/user/login_dto.dart @@ -0,0 +1,14 @@ +class LoginDto { + String accessToken; + + LoginDto({required this.accessToken}); + + Map toJson() { + final map = {}; + map["accessToken"] = accessToken; + + return map; + } + + LoginDto.fromJson(dynamic json) : accessToken = json["accessToken"] ?? ""; +} diff --git a/lib/request/dto/user/user_info_dto.dart b/lib/request/dto/user/user_info_dto.dart new file mode 100644 index 0000000..b82feac --- /dev/null +++ b/lib/request/dto/user/user_info_dto.dart @@ -0,0 +1,60 @@ +class UserInfoDto { + UserInfoDto({ + required this.accountType, + required this.extraInfo, + required this.name, + required this.tel, + required this.id, + required this.avatar, + }); + + /// 1学生 2老师 + int accountType; + ExtraInfo extraInfo; + String name; + String tel; + int id; + String avatar; + + factory UserInfoDto.fromJson(Map json) => UserInfoDto( + accountType: json["account_type"], + extraInfo: ExtraInfo.fromJson(json["extra_info"]), + name: json["name"], + tel: json["tel"], + id: json["id"], + avatar: json["avatar"], + ); + + Map toJson() => { + "account_type": accountType, + "extra_info": extraInfo.toJson(), + "name": name, + "tel": tel, + "id": id, + "avatar": avatar, + }; +} + +class ExtraInfo { + ExtraInfo({ + required this.vipEndTime, + required this.vipStartTime, + required this.vipStatus, + }); + + String vipEndTime; + String vipStartTime; + int vipStatus; // 0:普通用户 1:VIP + + factory ExtraInfo.fromJson(Map json) => ExtraInfo( + vipEndTime: json["vip_end_time"], + vipStartTime: json["vip_start_time"], + vipStatus: json["vip_status"], + ); + + Map toJson() => { + "vip_end_time": vipEndTime, + "vip_start_time": vipStartTime, + "vip_status": vipStatus, + }; +} diff --git a/lib/request/network/interceptor.dart b/lib/request/network/interceptor.dart index 5e7892e..fae793d 100644 --- a/lib/request/network/interceptor.dart +++ b/lib/request/network/interceptor.dart @@ -1,13 +1,16 @@ +import 'package:app/providers/user_store.dart'; import 'package:dio/dio.dart'; +import 'package:flutter_easyloading/flutter_easyloading.dart'; import '../dto/base_dto.dart'; - ///请求拦截器 void onRequest( RequestOptions options, RequestInterceptorHandler handler, -) { +) async { + String token = await UserStore.getToken(); + options.headers['Authorization'] = 'Bearer $token'; return handler.next(options); } @@ -18,6 +21,7 @@ void onResponse( ) { var apiResponse = ApiDto.fromJson(response.data); if (apiResponse.code == 1) { + response.data = apiResponse.data; handler.next(response); } else { handler.reject( @@ -35,17 +39,25 @@ void onError( DioException e, ErrorInterceptorHandler handler, ) { + var title = ""; if (e.type == DioExceptionType.connectionTimeout) { - print("请求超时"); + title = "请求超时"; } else if (e.type == DioExceptionType.badResponse) { if (e.response?.statusCode == 404) { - print("接口404不存在"); + title = "接口404不存在"; } else { - print("500"); + title = "500"; } } else if (e.type == DioExceptionType.connectionError) { - print("网络连接失败"); + title = "网络连接失败"; } else { - print("接口请求异常报错"); + title = "异常其他错误"; } + showError(title); + handler.next(e); +} + +///显示错误信息 +void showError(String message) { + EasyLoading.showError(message); } diff --git a/lib/request/websocket/room_protocol.dart b/lib/request/websocket/room_protocol.dart new file mode 100644 index 0000000..34578c9 --- /dev/null +++ b/lib/request/websocket/room_protocol.dart @@ -0,0 +1,103 @@ +enum RoomCommand { + ///ping服务器,用于心跳 + ping("ping"), + + ///加入房间 + joinRoom("into_room"), + + ///获取房间信息(没啥用) + getRoomInfo("room_data"), + + ///学生开关扬声器、摄像头、麦克风 + switchCamera("mute_self"), + + ///学生上传文件 + uploadFile("upload_file"), + + ///学生举手 + handUp("handup"), + + ///老师开启自习室 + openRoom("start_study_room"), + + ///老师关闭自习室 + closeRoom("close_study_room"), + + ///老师开关学生的扬声器、关闭摄像头、关闭麦克风 + switchStudentCamera("mute_user"), + + ///老师清除学生的举手 + clearHandUp("clear_handup"), + + ///邀请学生进入白板 + inviteStudent("invite_whiteboard"); + + final String value; + + const RoomCommand(this.value); +} + +enum RoomEvent { + ///人员变化事件 + changeUser("sys_room_user_changed"), + + ///学生端开启扬声器 + openSpeaker("user_unmute_self_speeker"), + + ///学生端关闭扬声器 + closeSpeaker("user_mute_self_speeker"), + + ///学生开启麦克风 + openMic("user_unmute_self_microphone"), + + ///学生关闭麦克风 + closeMic("user_mute_self_microphone"), + + ///学生开启摄像头 + openCamera("user_unmute_self_camera"), + + ///学生关闭摄像头 + closeCamera("user_mute_self_camera"), + + ///学生文件上传完毕 + fileUploadComplete("sys_user_file_uploaded"), + + ///学生举手事件 + handUp("sys_user_handup"), + + ///自习室以开启,进入自习室(学生用) + openRoom("sys_start_study_room"), + + ///自习室以关闭,退出自习室(学生用) + closeRoom("sys_close_study_room"), + + ///老师关闭学生的扬声器 + closeStudentSpeaker("sys_control_mute_speeker"), + + ///老师打开学生的扬声器 + openStudentSpeaker("sys_control_unmute_speeker"), + + ///老师关闭学生的麦克风 + closeStudentMic("sys_control_mute_microphone"), + + ///老师关闭学生的摄像头 + closeStudentCamera("sys_control_mute_camera"), + + ///老师清除学生的举手(学生用) + clearHandUp("sys_clear_handup"), + + ///学生收到白板邀请(学生用) + inviteWhiteboard("sys_invite_whiteboard"); + + final String value; + + const RoomEvent(this.value); + + /// 根据 值获取枚举 + static RoomEvent fromStr(String value) { + return RoomEvent.values.firstWhere( + (e) => e.value == value, + orElse: () => throw ArgumentError('Invalid weather type value: $value'), + ); + } +} diff --git a/lib/request/websocket/room_websocket.dart b/lib/request/websocket/room_websocket.dart new file mode 100644 index 0000000..13c38f9 --- /dev/null +++ b/lib/request/websocket/room_websocket.dart @@ -0,0 +1,122 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'package:app/config/config.dart'; +import 'package:app/request/api/room_api.dart'; +import 'package:app/request/websocket/room_protocol.dart'; +import 'package:logger/logger.dart'; + +import '../dto/room/rtc_token_dto.dart'; + +Logger logger = Logger(); + +class RoomWebSocket { + ///单例设计模式 + RoomWebSocket._(); + + static final RoomWebSocket _instance = RoomWebSocket._(); + + factory RoomWebSocket() => _instance; + + /// WebSocket和心跳定时器 + String url = ""; + WebSocket? _socket; + Timer? _heartbeatTimer; + Timer? _reconnectTimer; //错误重连的定时器 + + ///令牌 + String wsToken = ""; //自习室的websocket令牌 + int roomId = 0; //房间号 + RtcTokenDto? rtcToken; // rtc的令牌 + + ///用 StreamController 分化消息给订阅者 + final StreamController _msgController = StreamController.broadcast(); + + Stream get stream => _msgController.stream; + + ///初始化令牌 + /// -[id] 房间id + Future initToken(int id) async { + roomId = id; + final rtcFuture = getRtcTokenApi(id); + final wsFuture = getWsTokenApi(id); + + rtcToken = await rtcFuture; + wsToken = await wsFuture; + } + + ///开始连接 + Future connect() async { + try { + _socket = await WebSocket.connect( + "${Config.wsUrl()}?token=$wsToken&study_room_id=$roomId", + ); + logger.i("连接成功"); + _reconnectTimer?.cancel(); + _reconnectTimer = null; + + //监听消息 + _socket!.listen( + (data) { + //监听事件 + final jsonMap = jsonDecode(data); + RoomMessage msg = RoomMessage(RoomEvent.fromStr(jsonMap['action']), jsonMap['data']); + _msgController.add(msg); + }, + onDone: () {}, + onError: (_) { + logger.e("连接异常断开"); + }, + ); + //心跳 + _heartbeatTimer?.cancel(); + _heartbeatTimer = Timer.periodic(Duration(seconds: 15), (_) { + logger.i("发送心跳"); + send(RoomCommand.ping); + }); + } catch (e) { + logger.e("连接失败"); + _reconnect(); + } + } + + ///发送指令 + void send(RoomCommand action, [Map? params]) { + final msg = { + "action": action.value, + "data": params, + }; + _socket!.add(jsonEncode(msg)); + } + + ///连接错误事件 + void _reconnect() { + _reconnectTimer?.cancel(); + _reconnectTimer = Timer.periodic(Duration(seconds: 3), (timer) { + logger.e("正在重连"); + connect(); + }); + } + + void dispose() { + //心跳取消 + _heartbeatTimer?.cancel(); + _heartbeatTimer = null; + //socket取消 + _socket?.close(); + // 销毁事件流 + _msgController.close(); + // 错误重连取消 + _reconnectTimer?.cancel(); + _reconnectTimer = null; + logger.i("websocket销毁成功"); + } +} + +///websocket服务器发过来的事件和数据 +class RoomMessage { + final RoomEvent event; //事件名称 + final dynamic data; //事件数据 + + RoomMessage(this.event, this.data); +} diff --git a/lib/router/modules/teacher_routes.dart b/lib/router/modules/teacher_routes.dart index 01ca542..a8de2c7 100644 --- a/lib/router/modules/teacher_routes.dart +++ b/lib/router/modules/teacher_routes.dart @@ -14,7 +14,13 @@ List teacherRoutes = [ RouterConfig( path: RoutePaths.tRoom, child: (state) { - return TRoomPage(); + final extra = state.extra as Map?; + final roomId = extra?['roomId'] as int?; + final startTime = extra?['startTime'] as String?; + return TRoomPage( + roomId: roomId!, + startTime: startTime!, + ); }, ), -]; \ No newline at end of file +]; diff --git a/lib/router/routes.dart b/lib/router/routes.dart index a242534..cf60c3a 100644 --- a/lib/router/routes.dart +++ b/lib/router/routes.dart @@ -23,6 +23,6 @@ List routes = routeConfigs.map((item) { //变量命名 GoRouter goRouter = GoRouter( - initialLocation: RoutePaths.tHome, + initialLocation: RoutePaths.splash, routes: routes, ); diff --git a/lib/utils/permission.dart b/lib/utils/permission.dart new file mode 100644 index 0000000..1b867a9 --- /dev/null +++ b/lib/utils/permission.dart @@ -0,0 +1,65 @@ +import 'dart:ui'; +import 'package:permission_handler/permission_handler.dart'; + +/// 封装通用权限处理方法 +/// - [permissions] 需要检查的权限列表 +/// - [onGranted] 当所有权限都被授予时调用的回调 +/// - [onDenied] 当有权限被拒绝时调用的回调 +/// - [onPermanentlyDenied] 当有权限被永久拒绝时调用的回调(可选,默认打开设置页) +Future checkPermission({ + required List permissions, + required VoidCallback onGranted, + VoidCallback? onDenied, + VoidCallback? onPermanentlyDenied, +}) async { + // 判断当前权限状态 + Map statuses = {}; + for (final permission in permissions) { + statuses[permission] = await permission.status; + } + + // 筛选出未授权的权限 + final needRequest = statuses.entries + .where((entry) => !entry.value.isGranted) + .map((entry) => entry.key) + .toList(); + // 如果全部已有权限 + if (needRequest.isEmpty) { + onGranted(); + return; + } + + // 请求未授权的权限 + final requestResult = await needRequest.request(); + + //是否全部授权 + bool allGranted = true; + //是否有任何一个授权了 + bool anyPermanentlyDenied = false; + + for (final permission in permissions) { + final status = requestResult[permission] ?? await permission.status; + + if (status.isPermanentlyDenied) { + anyPermanentlyDenied = true; + allGranted = false; + break; + } else if (!status.isGranted) { + allGranted = false; + } + } + + if (allGranted) { + onGranted(); + } else if (anyPermanentlyDenied) { + if (onPermanentlyDenied != null) { + onPermanentlyDenied(); + } else { + openAppSettings(); // 可选:默认打开设置页 + } + } else { + if (onDenied != null) { + onDenied(); + } + } +} diff --git a/lib/utils/time.dart b/lib/utils/time.dart index 5d29ad6..e38c72b 100644 --- a/lib/utils/time.dart +++ b/lib/utils/time.dart @@ -46,3 +46,16 @@ String formatSeconds(int seconds) { return '${twoDigits(m)}:${twoDigits(s)}'; } } + + +/// 将 "HH", "HH:mm" 或 "HH:mm:ss" 转为当天 DateTime +DateTime parseTime(String timeStr) { + final now = DateTime.now(); + final parts = timeStr.split(':').map(int.parse).toList(); + + final hour = parts.length > 0 ? parts[0] : 0; + final minute = parts.length > 1 ? parts[1] : 0; + final second = parts.length > 2 ? parts[2] : 0; + + return DateTime(now.year, now.month, now.day, hour, minute, second); +} diff --git a/lib/websocket/room_websocket.dart b/lib/websocket/room_websocket.dart deleted file mode 100644 index 1e18775..0000000 --- a/lib/websocket/room_websocket.dart +++ /dev/null @@ -1,63 +0,0 @@ -import 'dart:async'; -import 'dart:io'; -import 'package:logger/logger.dart'; - -Logger logger = Logger(); - -class RoomWebSocket { - ///单例设计模式 - RoomWebSocket._(); - - static final RoomWebSocket _instance = RoomWebSocket._(); - - factory RoomWebSocket() => _instance; - - /// WebSocket和心跳定时器 - String url = ""; - WebSocket? _socket; - Timer? _heartbeatTimer; - - ///用 StreamController 分化消息给订阅者 - final StreamController> _msgController = StreamController.broadcast(); - - Stream> get stream => _msgController.stream; - - ///开始连接 - Future connect() async { - try { - _socket = await WebSocket.connect(url); - //监听消息 - _socket!.listen( - (data) {}, - onDone: () {}, - onError: (_) { - logger.e("连接异常断开"); - }, - ); - //心跳 - _heartbeatTimer?.cancel(); - _heartbeatTimer = Timer.periodic(Duration(seconds: 15), (_) { - logger.i("发送心跳"); - }); - } catch (e) { - _reconnect(); - } - } - - ///发送指令 - void send() { - _socket!.add(""); - } - - ///连接错误事件 - void _reconnect() { - logger.e("连接错误"); - Future.delayed(Duration(seconds: 3), connect); - } - - void dispose() { - _heartbeatTimer?.cancel(); - _socket?.close(); - _msgController.close(); - } -} diff --git a/lib/widgets/base/button/index.dart b/lib/widgets/base/button/index.dart index 20b4327..3c61852 100644 --- a/lib/widgets/base/button/index.dart +++ b/lib/widgets/base/button/index.dart @@ -34,7 +34,7 @@ class Button extends StatelessWidget { }; return Opacity( - opacity: disabled ? 0.5 : 1, + opacity: disabled || loading ? 0.5 : 1, child: Container( width: width, decoration: bgDecoration.copyWith(borderRadius: radius), @@ -47,14 +47,27 @@ class Button extends StatelessWidget { highlightColor: Colors.white.withValues(alpha: 0.2), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7), - child: Center( - child: Text( - text, - style: TextStyle( - color: type != ThemeType.info ? Colors.white : Colors.black, + child: Row( + spacing: 10, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (loading) + const SizedBox( + width: 15, + height: 15, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ), + Text( + text, + style: TextStyle( + color: type != ThemeType.info ? Colors.white : Colors.black, + ), + textAlign: TextAlign.center, ), - textAlign: TextAlign.center, - ), + ], ), ), ), diff --git a/lib/widgets/base/empty/index.dart b/lib/widgets/base/empty/index.dart new file mode 100644 index 0000000..d54755b --- /dev/null +++ b/lib/widgets/base/empty/index.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; + +enum EmptyType { + data, +} + +class Empty extends StatelessWidget { + final EmptyType type; + final String? text; + final Widget? child; + + const Empty({ + super.key, + this.type = EmptyType.data, + this.text, + this.child, + }); + + @override + Widget build(BuildContext context) { + var emptyImg = switch (type) { + EmptyType.data => Image.asset('assets/image/empty_data.png'), + }; + var emptyText = switch (type) { + EmptyType.data => '暂无数据', + }; + return Container( + padding: EdgeInsets.all(0), + child: Column( + children: [ + FractionallySizedBox( + widthFactor: 0.5, + child: Container( + margin: EdgeInsets.only(bottom: 15), + child: emptyImg, + ), + ), + Text( + text ?? emptyText, + style: Theme.of(context).textTheme.labelLarge, + textAlign: TextAlign.center, + ), + child != null + ? Container( + margin: EdgeInsets.only(top: 15), + child: child, + ) + : SizedBox(), + ], + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 1a12a5b..382f1bd 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,6 +1,14 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + agora_rtc_engine: + dependency: "direct main" + description: + name: agora_rtc_engine + sha256: "6559294d18ce4445420e19dbdba10fb58cac955cd8f22dbceae26716e194d70e" + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.5.3" async: dependency: transitive description: @@ -237,6 +245,30 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "0.20.2" + iris_method_channel: + dependency: transitive + description: + name: iris_method_channel + sha256: bfb5cfc6c6eae42da8cd1b35977a72d8b8881848a5dfc3d672e4760a907d11a0 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.4" + js: + dependency: transitive + description: + name: js + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.7.2" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.9.0" leak_tracker: dependency: transitive description: @@ -549,6 +581,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.4.1" + skeletonizer: + dependency: "direct main" + description: + name: skeletonizer + sha256: eebc03dc86b298e2d7f61e0ebce5713e9dbbc3e786f825909b4591756f196eb6 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.0+1" sky_engine: dependency: transitive description: flutter @@ -708,4 +748,4 @@ packages: version: "1.1.0" sdks: dart: ">=3.8.1 <4.0.0" - flutter: ">=3.29.0" + flutter: ">=3.31.0-0.0.pre" diff --git a/pubspec.yaml b/pubspec.yaml index 4fc3551..1aa9f51 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -15,7 +15,7 @@ dependencies: intl: any cupertino_icons: ^1.0.8 go_router: ^16.2.4 - permission_handler: ^12.0.0+1 + permission_handler: ^12.0.1 provider: ^6.1.5 shared_preferences: ^2.5.3 dio: ^5.8.0+1 @@ -26,6 +26,8 @@ dependencies: flutter_easyloading: ^3.0.5 cached_network_image: ^3.4.1 flutter_cached_pdfview: ^0.4.3 + skeletonizer: ^2.1.0+1 + agora_rtc_engine: ^6.5.3 dev_dependencies: flutter_test: @@ -34,4 +36,5 @@ dev_dependencies: flutter: uses-material-design: true - generate: true + assets: + - assets/image/ \ No newline at end of file