diff --git a/lib/config/routes.dart b/lib/config/routes.dart index 84dd75bbf..f6d8fc02e 100644 --- a/lib/config/routes.dart +++ b/lib/config/routes.dart @@ -33,8 +33,10 @@ import 'package:fluffychat/pangea/activity_planner/activity_planner_page.dart'; import 'package:fluffychat/pangea/analytics_page/analytics_page.dart'; import 'package:fluffychat/pangea/analytics_summary/progress_indicators_enum.dart'; import 'package:fluffychat/pangea/chat_settings/pages/pangea_invitation_selection.dart'; -import 'package:fluffychat/pangea/common/widgets/pangea_side_view.dart'; import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; +import 'package:fluffychat/pangea/course_creation/new_course_page.dart'; +import 'package:fluffychat/pangea/course_creation/selected_course_page.dart'; +import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; import 'package:fluffychat/pangea/find_your_people/find_your_people.dart'; import 'package:fluffychat/pangea/guard/p_vguard.dart'; import 'package:fluffychat/pangea/learning_settings/pages/settings_learning.dart'; @@ -206,46 +208,25 @@ abstract class AppRoutes { // #Pangea // FluffyThemes.isColumnMode(context) && // state.fullPath?.startsWith('/rooms/settings') == false - FluffyThemes.isColumnMode(context) && - state.fullPath?.startsWith('/rooms/settings') == false && - state.fullPath?.startsWith('/rooms/communities') == false && - state.fullPath?.startsWith('/rooms/analytics') == false - // Pangea# - ? TwoColumnLayout( - mainView: ChatList( - activeChat: state.pathParameters['roomid'], - // #Pangea - activeSpaceId: state.uri.queryParameters['spaceId'], - // Pangea# - displayNavigationRail: - state.path?.startsWith('/rooms/settings') != true, - ), - sideView: child, - ) - : child, + // ? TwoColumnLayout( + // mainView: ChatList( + // activeChat: state.pathParameters['roomid'], + // displayNavigationRail: + // state.path?.startsWith('/rooms/settings') != true, + // ), + // sideView: child, + // ) + // : child, + TwoColumnLayout( + state: state, + sideView: child, + ), + // Pangea# ), routes: [ GoRoute( path: '/rooms', - // #Pangea - // redirect: loggedOutRedirect, - redirect: (context, state) async { - final resp = await loggedOutRedirect(context, state); - if (resp != null) return resp; - final isColumnMode = FluffyThemes.isColumnMode(context); - - final spaceId = state.uri.queryParameters['spaceId']; - if (spaceId != null && - spaceId != 'clear' && - isColumnMode && - state.fullPath != null && - !state.fullPath!.contains('details')) { - return '/rooms/$spaceId/details?spaceId=$spaceId'; - } - - return null; - }, - // Pangea# + redirect: loggedOutRedirect, pageBuilder: (context, state) => defaultPageBuilder( context, state, @@ -256,9 +237,7 @@ abstract class AppRoutes { // Pangea# : ChatList( activeChat: state.pathParameters['roomid'], - // #Pangea - activeSpaceId: state.uri.queryParameters['spaceId'], - // Pangea# + activeSpaceId: state.pathParameters['spaceid'], ), ), routes: [ @@ -316,238 +295,366 @@ abstract class AppRoutes { redirect: loggedOutRedirect, ), // #Pangea - ShellRoute( - pageBuilder: (context, state, child) => defaultPageBuilder( + // ShellRoute( + // pageBuilder: (context, state, child) => defaultPageBuilder( + // context, + // state, + // FluffyThemes.isColumnMode(context) + // ? TwoColumnLayout( + // mainView: PangeaSideView(path: state.fullPath), + // sideView: child, + // ) + // : child, + // ), + // routes: [ + // Pangea# + GoRoute( + path: 'communities', + redirect: loggedOutRedirect, + pageBuilder: (context, state) => defaultPageBuilder( context, state, - FluffyThemes.isColumnMode(context) - ? TwoColumnLayout( - mainView: PangeaSideView(path: state.fullPath), - sideView: child, - dividerColor: Colors.transparent, - ) - : child, + const FindYourPeople(), ), routes: [ GoRoute( - path: 'communities', - redirect: loggedOutRedirect, + path: 'newcourse', pageBuilder: (context, state) => defaultPageBuilder( context, state, - const FindYourPeople(), + const NewCourse(), ), - ), - GoRoute( - path: 'analytics', redirect: loggedOutRedirect, - pageBuilder: (context, state) => defaultPageBuilder( - context, - state, - AnalyticsPage( - selectedIndicator: ProgressIndicatorEnum.fromString( - state.uri.queryParameters['mode'] ?? 'vocab', - ), - constructZoom: state.extra is ConstructIdentifier - ? state.extra as ConstructIdentifier - : null, - ), - ), routes: [ GoRoute( - path: ':roomid', + path: ':courseId', pageBuilder: (context, state) => defaultPageBuilder( context, state, - ChatPage( - roomId: state.pathParameters['roomid']!, - eventId: state.uri.queryParameters['event'], - backButton: BackButton( - onPressed: () => context.go( - "/rooms/analytics?mode=activities", - ), + SelectedCourse(state.pathParameters['courseId']!), + ), + redirect: loggedOutRedirect, + ), + ], + ), + ], + ), + GoRoute( + path: 'analytics', + redirect: loggedOutRedirect, + pageBuilder: (context, state) => defaultPageBuilder( + context, + state, + AnalyticsPage( + selectedIndicator: ProgressIndicatorEnum.fromString( + state.uri.queryParameters['mode'] ?? 'vocab', + ), + constructZoom: state.extra is ConstructIdentifier + ? state.extra as ConstructIdentifier + : null, + ), + ), + routes: [ + GoRoute( + path: ':roomid', + pageBuilder: (context, state) => defaultPageBuilder( + context, + state, + ChatPage( + roomId: state.pathParameters['roomid']!, + eventId: state.uri.queryParameters['event'], + backButton: BackButton( + onPressed: () => context.go( + "/rooms/analytics?mode=activities", + ), + ), + ), + ), + redirect: loggedOutRedirect, + ), + ], + ), + // Pangea# + // #Pangea + // ShellRoute( + // pageBuilder: (context, state, child) => defaultPageBuilder( + // context, + // state, + // FluffyThemes.isColumnMode(context) + // ? TwoColumnLayout( + // mainView: Settings(key: state.pageKey), + // sideView: child, + // ) + // : child, + // ), + // routes: [ + // Pangea# + GoRoute( + path: 'settings', + pageBuilder: (context, state) => defaultPageBuilder( + context, + state, + FluffyThemes.isColumnMode(context) + ? const EmptyPage() + : const Settings(), + ), + routes: [ + GoRoute( + path: 'notifications', + pageBuilder: (context, state) => defaultPageBuilder( + context, + state, + const SettingsNotifications(), + ), + redirect: loggedOutRedirect, + ), + GoRoute( + path: 'style', + pageBuilder: (context, state) => defaultPageBuilder( + context, + state, + const SettingsStyle(), + ), + redirect: loggedOutRedirect, + ), + GoRoute( + path: 'devices', + pageBuilder: (context, state) => defaultPageBuilder( + context, + state, + const DevicesSettings(), + ), + redirect: loggedOutRedirect, + ), + GoRoute( + path: 'chat', + pageBuilder: (context, state) => defaultPageBuilder( + context, + state, + const SettingsChat(), + ), + routes: [ + GoRoute( + path: 'emotes', + pageBuilder: (context, state) => defaultPageBuilder( + context, + state, + const EmotesSettings(), + ), + ), + ], + redirect: loggedOutRedirect, + ), + // #Pangea + // GoRoute( + // path: 'addaccount', + // redirect: loggedOutRedirect, + // pageBuilder: (context, state) => defaultPageBuilder( + // context, + // state, + // const HomeserverPicker(addMultiAccount: true), + // ), + // routes: [ + // GoRoute( + // path: 'login', + // pageBuilder: (context, state) => defaultPageBuilder( + // context, + // state, + // Login(client: state.extra as Client), + // ), + // redirect: loggedOutRedirect, + // ), + // ], + // ), + // Pangea# + GoRoute( + path: 'homeserver', + pageBuilder: (context, state) { + return defaultPageBuilder( + context, + state, + const SettingsHomeserver(), + ); + }, + redirect: loggedOutRedirect, + ), + GoRoute( + path: 'security', + redirect: loggedOutRedirect, + pageBuilder: (context, state) => defaultPageBuilder( + context, + state, + const SettingsSecurity(), + ), + routes: [ + GoRoute( + path: 'password', + pageBuilder: (context, state) { + return defaultPageBuilder( + context, + state, + const SettingsPassword(), + ); + }, + redirect: loggedOutRedirect, + ), + GoRoute( + path: 'ignorelist', + pageBuilder: (context, state) { + return defaultPageBuilder( + context, + state, + SettingsIgnoreList( + initialUserId: state.extra?.toString(), ), + ); + }, + redirect: loggedOutRedirect, + ), + GoRoute( + path: '3pid', + pageBuilder: (context, state) => defaultPageBuilder( + context, + state, + const Settings3Pid(), + ), + redirect: loggedOutRedirect, + ), + ], + ), + // #Pangea + GoRoute( + path: 'learning', + pageBuilder: (context, state) => defaultPageBuilder( + context, + state, + const SettingsLearning( + isDialog: false, + ), + ), + redirect: loggedOutRedirect, + ), + GoRoute( + path: 'subscription', + pageBuilder: (context, state) => defaultPageBuilder( + context, + state, + const SubscriptionManagement(), + ), + redirect: loggedOutRedirect, + ), + // Pangea# + ], + redirect: loggedOutRedirect, + ), + // #Pangea + GoRoute( + path: 'spaces', + pageBuilder: (context, state) => defaultPageBuilder( + context, + state, + const EmptyPage(), + ), + redirect: (context, state) { + if (state.pathParameters['spaceid'] == null) { + return "/rooms"; + } + return loggedOutRedirect(context, state); + }, + routes: [ + GoRoute( + path: ':spaceid', + pageBuilder: (context, state) => defaultPageBuilder( + context, + state, + ChatDetails( + roomId: state.pathParameters['spaceid']!, + ), + ), + redirect: loggedOutRedirect, + routes: [ + GoRoute( + path: 'details', + pageBuilder: (context, state) => defaultPageBuilder( + context, + state, + ChatDetails( + roomId: state.pathParameters['spaceid']!, ), ), redirect: loggedOutRedirect, + routes: roomDetailsRoutes('spaceid'), + ), + GoRoute( + path: ':roomid', + pageBuilder: (context, state) { + final body = state.uri.queryParameters['body']; + var shareItems = state.extra is List + ? state.extra as List + : null; + if (body != null && body.isNotEmpty) { + shareItems ??= []; + shareItems.add(TextShareItem(body)); + } + return defaultPageBuilder( + context, + state, + ChatPage( + roomId: state.pathParameters['roomid']!, + shareItems: shareItems, + eventId: state.uri.queryParameters['event'], + ), + ); + }, + redirect: loggedOutRedirect, + routes: [ + GoRoute( + path: 'search', + pageBuilder: (context, state) => defaultPageBuilder( + context, + state, + ChatSearchPage( + roomId: state.pathParameters['roomid']!, + ), + ), + redirect: loggedOutRedirect, + ), + GoRoute( + path: 'invite', + pageBuilder: (context, state) => defaultPageBuilder( + context, + state, + PangeaInvitationSelection( + roomId: state.pathParameters['roomid']!, + initialFilter: + state.uri.queryParameters['filter'] != null + ? InvitationFilter.fromString( + state.uri.queryParameters['filter']!, + ) + : null, + ), + ), + redirect: loggedOutRedirect, + ), + GoRoute( + path: 'details', + pageBuilder: (context, state) => defaultPageBuilder( + context, + state, + ChatDetails( + roomId: state.pathParameters['roomid']!, + ), + ), + routes: roomDetailsRoutes('roomid'), + redirect: loggedOutRedirect, + ), + ], ), ], ), ], ), // Pangea# - ShellRoute( - pageBuilder: (context, state, child) => defaultPageBuilder( - context, - state, - FluffyThemes.isColumnMode(context) - ? TwoColumnLayout( - mainView: Settings(key: state.pageKey), - sideView: child, - ) - : child, - ), - routes: [ - GoRoute( - path: 'settings', - pageBuilder: (context, state) => defaultPageBuilder( - context, - state, - FluffyThemes.isColumnMode(context) - ? const EmptyPage() - : const Settings(), - ), - routes: [ - GoRoute( - path: 'notifications', - pageBuilder: (context, state) => defaultPageBuilder( - context, - state, - const SettingsNotifications(), - ), - redirect: loggedOutRedirect, - ), - GoRoute( - path: 'style', - pageBuilder: (context, state) => defaultPageBuilder( - context, - state, - const SettingsStyle(), - ), - redirect: loggedOutRedirect, - ), - GoRoute( - path: 'devices', - pageBuilder: (context, state) => defaultPageBuilder( - context, - state, - const DevicesSettings(), - ), - redirect: loggedOutRedirect, - ), - GoRoute( - path: 'chat', - pageBuilder: (context, state) => defaultPageBuilder( - context, - state, - const SettingsChat(), - ), - routes: [ - GoRoute( - path: 'emotes', - pageBuilder: (context, state) => defaultPageBuilder( - context, - state, - const EmotesSettings(), - ), - ), - ], - redirect: loggedOutRedirect, - ), - // #Pangea - // GoRoute( - // path: 'addaccount', - // redirect: loggedOutRedirect, - // pageBuilder: (context, state) => defaultPageBuilder( - // context, - // state, - // const HomeserverPicker(addMultiAccount: true), - // ), - // routes: [ - // GoRoute( - // path: 'login', - // pageBuilder: (context, state) => defaultPageBuilder( - // context, - // state, - // Login(client: state.extra as Client), - // ), - // redirect: loggedOutRedirect, - // ), - // ], - // ), - // Pangea# - GoRoute( - path: 'homeserver', - pageBuilder: (context, state) { - return defaultPageBuilder( - context, - state, - const SettingsHomeserver(), - ); - }, - redirect: loggedOutRedirect, - ), - GoRoute( - path: 'security', - redirect: loggedOutRedirect, - pageBuilder: (context, state) => defaultPageBuilder( - context, - state, - const SettingsSecurity(), - ), - routes: [ - GoRoute( - path: 'password', - pageBuilder: (context, state) { - return defaultPageBuilder( - context, - state, - const SettingsPassword(), - ); - }, - redirect: loggedOutRedirect, - ), - GoRoute( - path: 'ignorelist', - pageBuilder: (context, state) { - return defaultPageBuilder( - context, - state, - SettingsIgnoreList( - initialUserId: state.extra?.toString(), - ), - ); - }, - redirect: loggedOutRedirect, - ), - GoRoute( - path: '3pid', - pageBuilder: (context, state) => defaultPageBuilder( - context, - state, - const Settings3Pid(), - ), - redirect: loggedOutRedirect, - ), - ], - ), - // #Pangea - GoRoute( - path: 'learning', - pageBuilder: (context, state) => defaultPageBuilder( - context, - state, - const SettingsLearning( - isDialog: false, - ), - ), - redirect: loggedOutRedirect, - ), - GoRoute( - path: 'subscription', - pageBuilder: (context, state) => defaultPageBuilder( - context, - state, - const SubscriptionManagement(), - ), - redirect: loggedOutRedirect, - ), - // Pangea# - ], - redirect: loggedOutRedirect, - ), - ], - ), GoRoute( path: ':roomid', pageBuilder: (context, state) { @@ -569,7 +676,23 @@ abstract class AppRoutes { ), ); }, - redirect: loggedOutRedirect, + // #Pangea + // redirect: loggedOutRedirect, + redirect: (context, state) { + final subroute = state.fullPath!.split('roomid').last; + final roomId = state.pathParameters['roomid']!; + final room = Matrix.of(context).client.getRoomById(roomId); + if (room != null && room.isSpace) { + return "/rooms/spaces/${room.id}$subroute"; + } + + final parent = room?.firstSpaceParent; + if (parent != null && state.fullPath != null) { + return "/rooms/spaces/${parent.id}/$roomId$subroute"; + } + return loggedOutRedirect(context, state); + }, + // Pangea# routes: [ GoRoute( path: 'search', @@ -618,123 +741,86 @@ abstract class AppRoutes { roomId: state.pathParameters['roomid']!, ), ), - routes: [ - // #Pangea - GoRoute( - path: '/analytics', - redirect: loggedOutRedirect, - pageBuilder: (context, state) => defaultPageBuilder( - context, - state, - SpaceAnalytics( - roomId: state.pathParameters['roomid']!, - ), - ), - ), - GoRoute( - path: 'planner', - pageBuilder: (context, state) => defaultPageBuilder( - context, - state, - ActivityPlannerPage( - roomID: state.pathParameters['roomid']!, - ), - ), - redirect: loggedOutRedirect, - routes: [ - GoRoute( - path: '/generator', - redirect: loggedOutRedirect, - pageBuilder: (context, state) => defaultPageBuilder( - context, - state, - ActivityGenerator( - roomID: state.pathParameters['roomid']!, - ), - ), - ), - ], - ), - // Pangea# - GoRoute( - path: 'access', - pageBuilder: (context, state) => defaultPageBuilder( - context, - state, - ChatAccessSettings( - roomId: state.pathParameters['roomid']!, - ), - ), - redirect: loggedOutRedirect, - ), - GoRoute( - path: 'members', - pageBuilder: (context, state) => defaultPageBuilder( - context, - state, - ChatMembersPage( - roomId: state.pathParameters['roomid']!, - // #Pangea - filter: state.uri.queryParameters['filter'], - // Pangea# - ), - ), - redirect: loggedOutRedirect, - ), - GoRoute( - path: 'permissions', - pageBuilder: (context, state) => defaultPageBuilder( - context, - state, - const ChatPermissionsSettings(), - ), - redirect: loggedOutRedirect, - ), - GoRoute( - path: 'invite', - pageBuilder: (context, state) => defaultPageBuilder( - context, - state, - PangeaInvitationSelection( - roomId: state.pathParameters['roomid']!, - initialFilter: - state.uri.queryParameters['filter'] != null - ? InvitationFilter.fromString( - state.uri.queryParameters['filter']!, - ) - : null, - ), - ), - redirect: loggedOutRedirect, - ), - GoRoute( - path: 'multiple_emotes', - pageBuilder: (context, state) => defaultPageBuilder( - context, - state, - const MultipleEmotesSettings(), - ), - redirect: loggedOutRedirect, - ), - GoRoute( - path: 'emotes', - pageBuilder: (context, state) => defaultPageBuilder( - context, - state, - const EmotesSettings(), - ), - redirect: loggedOutRedirect, - ), - GoRoute( - path: 'emotes/:state_key', - pageBuilder: (context, state) => defaultPageBuilder( - context, - state, - const EmotesSettings(), - ), - redirect: loggedOutRedirect, - ), - ], + // #Pangea + routes: roomDetailsRoutes('roomid'), + // routes: [ + // GoRoute( + // path: 'access', + // pageBuilder: (context, state) => defaultPageBuilder( + // context, + // state, + // ChatAccessSettings( + // roomId: state.pathParameters['roomid']!, + // ), + // ), + // redirect: loggedOutRedirect, + // ), + // GoRoute( + // path: 'members', + // pageBuilder: (context, state) => defaultPageBuilder( + // context, + // state, + // ChatMembersPage( + // roomId: state.pathParameters['roomid']!, + // ), + // ), + // redirect: loggedOutRedirect, + // ), + // GoRoute( + // path: 'permissions', + // pageBuilder: (context, state) => defaultPageBuilder( + // context, + // state, + // const ChatPermissionsSettings(), + // ), + // redirect: loggedOutRedirect, + // ), + // GoRoute( + // path: 'invite', + // pageBuilder: (context, state) => defaultPageBuilder( + // context, + // state, + // PangeaInvitationSelection( + // roomId: state.pathParameters['roomid']!, + // initialFilter: + // state.uri.queryParameters['filter'] != null + // ? InvitationFilter.fromString( + // state.uri.queryParameters['filter']!, + // ) + // : null, + // ), + // ), + // redirect: loggedOutRedirect, + // ), + // GoRoute( + // path: 'multiple_emotes', + // pageBuilder: (context, state) => defaultPageBuilder( + // context, + // state, + // const MultipleEmotesSettings(), + // ), + // redirect: loggedOutRedirect, + // ), + // GoRoute( + // path: 'emotes', + // pageBuilder: (context, state) => defaultPageBuilder( + // context, + // state, + // const EmotesSettings(), + // ), + // redirect: loggedOutRedirect, + // ), + // GoRoute( + // path: 'emotes/:state_key', + // pageBuilder: (context, state) => defaultPageBuilder( + // context, + // state, + // const EmotesSettings(), + // ), + // redirect: loggedOutRedirect, + // ), + // ], + // Pangea# redirect: loggedOutRedirect, ), ], @@ -793,5 +879,118 @@ abstract class AppRoutes { redirect: loggedOutRedirect, ), ]; + + static List roomDetailsRoutes(String roomKey) => [ + GoRoute( + path: '/analytics', + redirect: loggedOutRedirect, + pageBuilder: (context, state) => defaultPageBuilder( + context, + state, + SpaceAnalytics( + roomId: state.pathParameters[roomKey]!, + ), + ), + ), + GoRoute( + path: 'planner', + pageBuilder: (context, state) => defaultPageBuilder( + context, + state, + ActivityPlannerPage( + roomID: state.pathParameters[roomKey]!, + ), + ), + redirect: loggedOutRedirect, + routes: [ + GoRoute( + path: '/generator', + redirect: loggedOutRedirect, + pageBuilder: (context, state) => defaultPageBuilder( + context, + state, + ActivityGenerator( + roomID: state.pathParameters[roomKey]!, + ), + ), + ), + ], + ), + GoRoute( + path: 'access', + pageBuilder: (context, state) => defaultPageBuilder( + context, + state, + ChatAccessSettings( + roomId: state.pathParameters[roomKey]!, + ), + ), + redirect: loggedOutRedirect, + ), + GoRoute( + path: 'members', + pageBuilder: (context, state) => defaultPageBuilder( + context, + state, + ChatMembersPage( + roomId: state.pathParameters[roomKey]!, + filter: state.uri.queryParameters['filter'], + ), + ), + redirect: loggedOutRedirect, + ), + GoRoute( + path: 'permissions', + pageBuilder: (context, state) => defaultPageBuilder( + context, + state, + const ChatPermissionsSettings(), + ), + redirect: loggedOutRedirect, + ), + GoRoute( + path: 'invite', + pageBuilder: (context, state) => defaultPageBuilder( + context, + state, + PangeaInvitationSelection( + roomId: state.pathParameters[roomKey]!, + initialFilter: state.uri.queryParameters['filter'] != null + ? InvitationFilter.fromString( + state.uri.queryParameters['filter']!, + ) + : null, + ), + ), + redirect: loggedOutRedirect, + ), + GoRoute( + path: 'multiple_emotes', + pageBuilder: (context, state) => defaultPageBuilder( + context, + state, + const MultipleEmotesSettings(), + ), + redirect: loggedOutRedirect, + ), + GoRoute( + path: 'emotes', + pageBuilder: (context, state) => defaultPageBuilder( + context, + state, + const EmotesSettings(), + ), + redirect: loggedOutRedirect, + ), + GoRoute( + path: 'emotes/:state_key', + pageBuilder: (context, state) => defaultPageBuilder( + context, + state, + const EmotesSettings(), + ), + redirect: loggedOutRedirect, + ), + ]; // Pangea# } diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 2c78e91bd..a93f4551e 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -3387,7 +3387,7 @@ "chatTopic": "Chat topic", "chatTopicDesc": "Set a chat topic", "inviteStudentByUserNameDesc": "If your student already has an account, you can search for them.", - "classRoster": "Participants", + "participants": "Participants", "almostPerfect": "That seems right! Here's what I would have said.", "prettyGood": "Pretty good! Here's what I would have said.", "letMeThink": "Hmm, let's see how you did!", @@ -5192,5 +5192,54 @@ } }, "activityFinishedMessage": "All Finished!", - "endForAll": "End for all" + "endForAll": "End for all", + "newCourse": "New Course", + "newCourseSubtitle": "Which course template would you like to use?", + "failedToLoadCourses": "Failed to load courses", + "numModules": "{num} modules", + "@numModules": { + "type": "int", + "placeholders": { + "num": { + "type": "int" + } + } + }, + "numActivityPlans": "{num} activity plans", + "@numActivityPlans": { + "type": "int", + "placeholders": { + "num": { + "type": "int" + } + } + }, + "coursePlan": "Course Plan", + "editCourseLater": "You can edit template title, descriptions, and course image later.", + "newCourseAccess": "By default, courses are private and require admin approval to join. You can edit these settings at any time.", + "createCourse": "Create course", + "stats": "Stats", + "createGroupChat": "Create group chat", + "editCourse": "Edit course", + "inviteDesc": "By username, by code or link", + "editCourseDesc": "Here you can edit course title, description, etc.", + "permissionsDesc": "Set permissions such as who can invite users, send messages, create chats, etc.", + "accessDesc": "You can make your course open to the world! Or, make your course private and secure.", + "createGroupChatDesc": "Whereas activity sessions start and end, group chats will stay open for routine communication.", + "deleteDesc": "Only space admin can delete a chat. This is a destructive action which removes all users and deletes all chats. Proceed with caution.", + "failedToLoadCourseInfo": "Failed to load course information", + "noCourseFound": "No course information found", + "additionalParticipants": "+ {num} others", + "@additionalParticipants": { + "type": "int", + "placeholders": { + "num": { + "type": "int" + } + } + }, + "activityNotFoundForCourse": "This activity was not found within the course", + "courseChats": "Course Chats", + "noSessionsFound": "None found. Ready to start?", + "myActivitySessions": "My Activity Sessions" } diff --git a/lib/pages/chat_details/chat_details.dart b/lib/pages/chat_details/chat_details.dart index ddd1e2d91..3826010ce 100644 --- a/lib/pages/chat_details/chat_details.dart +++ b/lib/pages/chat_details/chat_details.dart @@ -6,16 +6,19 @@ import 'package:flutter/material.dart'; import 'package:collection/collection.dart'; import 'package:go_router/go_router.dart'; import 'package:image_picker/image_picker.dart'; +import 'package:matrix/matrix.dart' as sdk; import 'package:matrix/matrix.dart'; import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/pages/settings/settings.dart'; +import 'package:fluffychat/pangea/chat/constants/default_power_level.dart'; import 'package:fluffychat/pangea/chat_settings/models/bot_options_model.dart'; -import 'package:fluffychat/pangea/chat_settings/pages/pangea_chat_details.dart'; +import 'package:fluffychat/pangea/chat_settings/pages/pangea_room_details.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/download/download_room_extension.dart'; import 'package:fluffychat/pangea/download/download_type_enum.dart'; import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart'; +import 'package:fluffychat/pangea/extensions/join_rule_extension.dart'; import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; import 'package:fluffychat/utils/file_selector.dart'; import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart'; @@ -221,7 +224,7 @@ class ChatDetailsController extends State { @override // #Pangea - Widget build(BuildContext context) => PangeaChatDetailsView(this); + Widget build(BuildContext context) => PangeaRoomDetailsView(this); // Widget build(BuildContext context) => ChatDetailsView(this); // Pangea# @@ -343,10 +346,69 @@ class ChatDetailsController extends State { } } - Future addSubspace() async { + Future addGroupChat() async { final activeSpace = Matrix.of(context).client.getRoomById(roomId!); if (activeSpace == null || !activeSpace.isSpace) return; - await activeSpace.addSubspace(context); + + final names = await showTextInputDialog( + context: context, + title: L10n.of(context).createGroup, + hintText: L10n.of(context).groupName, + minLines: 1, + maxLines: 1, + maxLength: 64, + validator: (text) { + if (text.isEmpty) { + return L10n.of(context).pleaseChoose; + } + return null; + }, + okLabel: L10n.of(context).create, + cancelLabel: L10n.of(context).cancel, + ); + if (names == null) return; + + final resp = await showFutureLoadingDialog( + context: context, + future: () async { + final newRoomId = await Matrix.of(context).client.createGroupChat( + visibility: sdk.Visibility.private, + groupName: names, + initialState: [ + RoomDefaults.defaultPowerLevels( + Matrix.of(context).client.userID!, + ), + await Matrix.of(context).client.pangeaJoinRules( + 'knock_restricted', + allow: roomId != null + ? [ + { + "type": "m.room_membership", + "room_id": roomId, + } + ] + : null, + ), + ], + enableEncryption: false, + ); + final client = Matrix.of(context).client; + Room? room = client.getRoomById(newRoomId); + if (room == null) { + await client.waitForRoomInSync(newRoomId); + room = client.getRoomById(newRoomId); + } + if (room == null) newRoomId; + await activeSpace.addToSpace(room!.id); + if (room.spaceParents.isEmpty) { + await client.waitForRoomInSync(newRoomId); + } + return newRoomId; + }, + ); + + if (resp.isError || resp.result == null || !mounted) return; + context.go('/rooms/${resp.result}/invite'); } // Pangea# } diff --git a/lib/pages/chat_encryption_settings/chat_encryption_settings.dart b/lib/pages/chat_encryption_settings/chat_encryption_settings.dart index 704f33641..818b6ff87 100644 --- a/lib/pages/chat_encryption_settings/chat_encryption_settings.dart +++ b/lib/pages/chat_encryption_settings/chat_encryption_settings.dart @@ -20,7 +20,13 @@ class ChatEncryptionSettings extends StatefulWidget { } class ChatEncryptionSettingsController extends State { - String? get roomId => GoRouterState.of(context).pathParameters['roomid']; + // #Pangea + // String? get roomId => GoRouterState.of(context).pathParameters['roomid']; + String? get roomId { + final pathParameters = GoRouterState.of(context).pathParameters; + return pathParameters['roomid'] ?? pathParameters['spaceid']; + } + // Pangea# Room get room => Matrix.of(context).client.getRoomById(roomId!)!; diff --git a/lib/pages/chat_list/chat_list.dart b/lib/pages/chat_list/chat_list.dart index bc35ced83..ec26ce595 100644 --- a/lib/pages/chat_list/chat_list.dart +++ b/lib/pages/chat_list/chat_list.dart @@ -13,7 +13,6 @@ import 'package:matrix/matrix.dart'; import 'package:receive_sharing_intent/receive_sharing_intent.dart'; import 'package:fluffychat/config/app_config.dart'; -import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/pages/chat_list/chat_list_view.dart'; import 'package:fluffychat/pangea/chat_list/utils/app_version_util.dart'; @@ -113,33 +112,25 @@ class ChatListController extends State ? ActiveFilter.messages : ActiveFilter.allChats; - String? _activeSpaceId; - String? get activeSpaceId => _activeSpaceId; - - void setActiveSpace(String spaceId) async { - await Matrix.of(context).client.getRoomById(spaceId)!.postLoad(); - - // #Pangea - if (FluffyThemes.isColumnMode(context)) { - context.push("/rooms/$spaceId/details"); - } - // Pangea# - - setState(() { - _activeSpaceId = spaceId; - }); - } - // #Pangea + String? get activeSpaceId => widget.activeSpaceId; + // String? _activeSpaceId; + // String? get activeSpaceId => _activeSpaceId; + + // void setActiveSpace(String spaceId) async { + // await Matrix.of(context).client.getRoomById(spaceId)!.postLoad(); + + // setState(() { + // _activeSpaceId = spaceId; + // }); + // } + // void clearActiveSpace() => setState(() { // _activeSpaceId = null; // }); - void clearActiveSpace() { - setState(() { - _activeSpaceId = null; - }); - context.go("/rooms"); - } + void clearActiveSpace() => context.go("/rooms"); + void setActiveSpace(String spaceId) => + context.go("/rooms/spaces/$spaceId/details"); // Pangea# void onChatTap(Room room) async { @@ -524,9 +515,7 @@ class ChatListController extends State //#Pangea StreamSubscription? _invitedSpaceSubscription; StreamSubscription? _subscriptionStatusStream; - StreamSubscription? _spaceChildSubscription; StreamSubscription? _roomCapacitySubscription; - final Set hasUpdates = {}; //Pangea# @override @@ -625,11 +614,6 @@ class ChatListController extends State // so that when the user navigates to the space that was updated, it will // reload any rooms that have been added / removed final client = MatrixState.pangeaController.matrixState.client; - _spaceChildSubscription ??= client.onRoomState.stream.where((u) { - return u.state.type == EventTypes.SpaceChild && u.roomId != activeSpaceId; - }).listen((update) { - hasUpdates.add(update.roomId); - }); // listen for room join events and leave room if over capacity _roomCapacitySubscription ??= client.onSync.stream @@ -669,9 +653,6 @@ class ChatListController extends State } }); - _activeSpaceId = - widget.activeSpaceId == 'clear' ? null : widget.activeSpaceId; - WidgetsBinding.instance.addPostFrameCallback((_) { _joinInvitedSpaces(); }); @@ -681,17 +662,6 @@ class ChatListController extends State } // #Pangea - @override - void didUpdateWidget(ChatList oldWidget) { - super.didUpdateWidget(oldWidget); - if (widget.activeSpaceId != oldWidget.activeSpaceId && - widget.activeSpaceId != null) { - widget.activeSpaceId == 'clear' - ? clearActiveSpace() - : setActiveSpace(widget.activeSpaceId!); - } - } - Future _joinInvitedSpaces() async { final invitedSpaces = Matrix.of(context).client.rooms.where( (r) => r.isSpace && r.membership == Membership.invite, @@ -711,7 +681,6 @@ class ChatListController extends State //#Pangea _invitedSpaceSubscription?.cancel(); _subscriptionStatusStream?.cancel(); - _spaceChildSubscription?.cancel(); _roomCapacitySubscription?.cancel(); //Pangea# scrollController.removeListener(_onScroll); @@ -1097,7 +1066,7 @@ class ChatListController extends State builder: (_) => DeleteSpaceDialog(space: room), ); if (resp == true && mounted) { - context.go("/rooms?spaceId=clear"); + context.go("/rooms"); } } else { final confirmed = await showOkCancelAlertDialog( @@ -1118,7 +1087,9 @@ class ChatListController extends State future: room.delete, ); if (mounted && !resp.isError) { - context.go("/rooms"); + activeSpaceId != null + ? context.go('/rooms/spaces/$activeSpaceId/details') + : context.go("/rooms"); } } return; @@ -1257,29 +1228,31 @@ class ChatListController extends State }); } - void setActiveClient(Client client) { - context.go('/rooms'); - setState(() { - activeFilter = ActiveFilter.allChats; - _activeSpaceId = null; - Matrix.of(context).setActiveClient(client); - }); - _clientStream.add(client); - } + // #Pangea + // void setActiveClient(Client client) { + // context.go('/rooms'); + // setState(() { + // activeFilter = ActiveFilter.allChats; + // _activeSpaceId = null; + // Matrix.of(context).setActiveClient(client); + // }); + // _clientStream.add(client); + // } - void setActiveBundle(String bundle) { - context.go('/rooms'); - setState(() { - _activeSpaceId = null; - Matrix.of(context).activeBundle = bundle; - if (!Matrix.of(context) - .currentBundle! - .any((client) => client == Matrix.of(context).client)) { - Matrix.of(context) - .setActiveClient(Matrix.of(context).currentBundle!.first); - } - }); - } + // void setActiveBundle(String bundle) { + // context.go('/rooms'); + // setState(() { + // _activeSpaceId = null; + // Matrix.of(context).activeBundle = bundle; + // if (!Matrix.of(context) + // .currentBundle! + // .any((client) => client == Matrix.of(context).client)) { + // Matrix.of(context) + // .setActiveClient(Matrix.of(context).currentBundle!.first); + // } + // }); + // } + // Pangea# void editBundlesForAccount(String? userId, String? activeBundle) async { final l10n = L10n.of(context); diff --git a/lib/pages/chat_list/chat_list_body.dart b/lib/pages/chat_list/chat_list_body.dart index 9f8c2d745..12655a1f6 100644 --- a/lib/pages/chat_list/chat_list_body.dart +++ b/lib/pages/chat_list/chat_list_body.dart @@ -9,8 +9,8 @@ import 'package:fluffychat/pages/chat_list/chat_list.dart'; import 'package:fluffychat/pages/chat_list/chat_list_item.dart'; import 'package:fluffychat/pages/chat_list/dummy_chat_list_item.dart'; import 'package:fluffychat/pages/chat_list/search_title.dart'; -import 'package:fluffychat/pages/chat_list/space_view.dart'; import 'package:fluffychat/pangea/chat_list/widgets/pangea_chat_list_header.dart'; +import 'package:fluffychat/pangea/course_chats/course_chats_page.dart'; import 'package:fluffychat/pangea/onboarding/onboarding.dart'; import 'package:fluffychat/pangea/public_spaces/public_room_bottom_sheet.dart'; import 'package:fluffychat/utils/stream_extension.dart'; @@ -31,19 +31,24 @@ class ChatListViewBody extends StatelessWidget { final client = Matrix.of(context).client; final activeSpace = controller.activeSpaceId; if (activeSpace != null) { - return SpaceView( + // #Pangea + // return SpaceView( + // key: ValueKey(activeSpace), + // spaceId: activeSpace, + // onBack: controller.clearActiveSpace, + // onChatTab: (room) => controller.onChatTap(room), + // onChatContext: (room, context) => + // controller.chatContextAction(room, context), + // activeChat: controller.activeChat, + // toParentSpace: controller.setActiveSpace, + // ); + return CourseChats( + activeSpace, key: ValueKey(activeSpace), - spaceId: activeSpace, - onBack: controller.clearActiveSpace, - onChatTab: (room) => controller.onChatTap(room), - onChatContext: (room, context) => - controller.chatContextAction(room, context), activeChat: controller.activeChat, - toParentSpace: controller.setActiveSpace, - // #Pangea - controller: controller, - // Pangea# + client: client, ); + // Pangea# } final spaces = client.rooms.where((r) => r.isSpace); final spaceDelegateCandidates = {}; diff --git a/lib/pages/chat_list/chat_list_header.dart b/lib/pages/chat_list/chat_list_header.dart index ca2bfcbe2..d994059d1 100644 --- a/lib/pages/chat_list/chat_list_header.dart +++ b/lib/pages/chat_list/chat_list_header.dart @@ -1,140 +1,142 @@ -import 'package:flutter/material.dart'; +// #Pangea +// import 'package:flutter/material.dart'; -import 'package:matrix/matrix.dart'; +// import 'package:matrix/matrix.dart'; -import 'package:fluffychat/config/themes.dart'; -import 'package:fluffychat/l10n/l10n.dart'; -import 'package:fluffychat/pages/chat_list/chat_list.dart'; -import 'package:fluffychat/pages/chat_list/client_chooser_button.dart'; -import 'package:fluffychat/utils/sync_status_localization.dart'; -import '../../widgets/matrix.dart'; +// import 'package:fluffychat/config/themes.dart'; +// import 'package:fluffychat/l10n/l10n.dart'; +// import 'package:fluffychat/pages/chat_list/chat_list.dart'; +// import 'package:fluffychat/pages/chat_list/client_chooser_button.dart'; +// import 'package:fluffychat/utils/sync_status_localization.dart'; +// import '../../widgets/matrix.dart'; -class ChatListHeader extends StatelessWidget implements PreferredSizeWidget { - final ChatListController controller; - final bool globalSearch; +// class ChatListHeader extends StatelessWidget implements PreferredSizeWidget { +// final ChatListController controller; +// final bool globalSearch; - const ChatListHeader({ - super.key, - required this.controller, - this.globalSearch = true, - }); +// const ChatListHeader({ +// super.key, +// required this.controller, +// this.globalSearch = true, +// }); - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final client = Matrix.of(context).client; +// @override +// Widget build(BuildContext context) { +// final theme = Theme.of(context); +// final client = Matrix.of(context).client; - return SliverAppBar( - floating: true, - toolbarHeight: 72, - pinned: FluffyThemes.isColumnMode(context), - scrolledUnderElevation: 0, - backgroundColor: Colors.transparent, - automaticallyImplyLeading: false, - title: StreamBuilder( - stream: client.onSyncStatus.stream, - builder: (context, snapshot) { - final status = client.onSyncStatus.value ?? - const SyncStatusUpdate(SyncStatus.waitingForResponse); - final hide = client.onSync.value != null && - status.status != SyncStatus.error && - client.prevBatch != null; - return TextField( - controller: controller.searchController, - focusNode: controller.searchFocusNode, - textInputAction: TextInputAction.search, - onChanged: (text) => controller.onSearchEnter( - text, - globalSearch: globalSearch, - ), - decoration: InputDecoration( - filled: true, - fillColor: theme.colorScheme.secondaryContainer, - border: OutlineInputBorder( - borderSide: BorderSide.none, - borderRadius: BorderRadius.circular(99), - ), - contentPadding: EdgeInsets.zero, - hintText: hide - ? L10n.of(context).searchChatsRooms - : status.calcLocalizedString(context), - hintStyle: TextStyle( - color: status.error != null - ? Colors.orange - : theme.colorScheme.onPrimaryContainer, - fontWeight: FontWeight.normal, - ), - prefixIcon: hide - ? controller.isSearchMode - ? IconButton( - tooltip: L10n.of(context).cancel, - icon: const Icon(Icons.close_outlined), - onPressed: controller.cancelSearch, - color: theme.colorScheme.onPrimaryContainer, - ) - : IconButton( - onPressed: controller.startSearch, - icon: Icon( - Icons.search_outlined, - color: theme.colorScheme.onPrimaryContainer, - ), - ) - : Container( - margin: const EdgeInsets.all(12), - width: 8, - height: 8, - child: Center( - child: CircularProgressIndicator.adaptive( - strokeWidth: 2, - value: status.progress, - valueColor: status.error != null - ? const AlwaysStoppedAnimation( - Colors.orange, - ) - : null, - ), - ), - ), - suffixIcon: controller.isSearchMode && globalSearch - ? controller.isSearching - ? const Padding( - padding: EdgeInsets.symmetric( - vertical: 10.0, - horizontal: 12, - ), - child: SizedBox.square( - dimension: 24, - child: CircularProgressIndicator.adaptive( - strokeWidth: 2, - ), - ), - ) - : TextButton.icon( - onPressed: controller.setServer, - style: TextButton.styleFrom( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(99), - ), - textStyle: const TextStyle(fontSize: 12), - ), - icon: const Icon(Icons.edit_outlined, size: 16), - label: Text( - controller.searchServer ?? - Matrix.of(context).client.homeserver!.host, - maxLines: 2, - ), - ) - : SizedBox( - width: 0, - child: ClientChooserButton(controller), - ), - ), - ); - }, - ), - ); - } +// return SliverAppBar( +// floating: true, +// toolbarHeight: 72, +// pinned: FluffyThemes.isColumnMode(context), +// scrolledUnderElevation: 0, +// backgroundColor: Colors.transparent, +// automaticallyImplyLeading: false, +// title: StreamBuilder( +// stream: client.onSyncStatus.stream, +// builder: (context, snapshot) { +// final status = client.onSyncStatus.value ?? +// const SyncStatusUpdate(SyncStatus.waitingForResponse); +// final hide = client.onSync.value != null && +// status.status != SyncStatus.error && +// client.prevBatch != null; +// return TextField( +// controller: controller.searchController, +// focusNode: controller.searchFocusNode, +// textInputAction: TextInputAction.search, +// onChanged: (text) => controller.onSearchEnter( +// text, +// globalSearch: globalSearch, +// ), +// decoration: InputDecoration( +// filled: true, +// fillColor: theme.colorScheme.secondaryContainer, +// border: OutlineInputBorder( +// borderSide: BorderSide.none, +// borderRadius: BorderRadius.circular(99), +// ), +// contentPadding: EdgeInsets.zero, +// hintText: hide +// ? L10n.of(context).searchChatsRooms +// : status.calcLocalizedString(context), +// hintStyle: TextStyle( +// color: status.error != null +// ? Colors.orange +// : theme.colorScheme.onPrimaryContainer, +// fontWeight: FontWeight.normal, +// ), +// prefixIcon: hide +// ? controller.isSearchMode +// ? IconButton( +// tooltip: L10n.of(context).cancel, +// icon: const Icon(Icons.close_outlined), +// onPressed: controller.cancelSearch, +// color: theme.colorScheme.onPrimaryContainer, +// ) +// : IconButton( +// onPressed: controller.startSearch, +// icon: Icon( +// Icons.search_outlined, +// color: theme.colorScheme.onPrimaryContainer, +// ), +// ) +// : Container( +// margin: const EdgeInsets.all(12), +// width: 8, +// height: 8, +// child: Center( +// child: CircularProgressIndicator.adaptive( +// strokeWidth: 2, +// value: status.progress, +// valueColor: status.error != null +// ? const AlwaysStoppedAnimation( +// Colors.orange, +// ) +// : null, +// ), +// ), +// ), +// suffixIcon: controller.isSearchMode && globalSearch +// ? controller.isSearching +// ? const Padding( +// padding: EdgeInsets.symmetric( +// vertical: 10.0, +// horizontal: 12, +// ), +// child: SizedBox.square( +// dimension: 24, +// child: CircularProgressIndicator.adaptive( +// strokeWidth: 2, +// ), +// ), +// ) +// : TextButton.icon( +// onPressed: controller.setServer, +// style: TextButton.styleFrom( +// shape: RoundedRectangleBorder( +// borderRadius: BorderRadius.circular(99), +// ), +// textStyle: const TextStyle(fontSize: 12), +// ), +// icon: const Icon(Icons.edit_outlined, size: 16), +// label: Text( +// controller.searchServer ?? +// Matrix.of(context).client.homeserver!.host, +// maxLines: 2, +// ), +// ) +// : SizedBox( +// width: 0, +// child: ClientChooserButton(controller), +// ), +// ), +// ); +// }, +// ), +// ); +// } - @override - Size get preferredSize => const Size.fromHeight(56); -} +// @override +// Size get preferredSize => const Size.fromHeight(56); +// } +// Pangea# diff --git a/lib/pages/chat_list/chat_list_view.dart b/lib/pages/chat_list/chat_list_view.dart index 168a60dbc..55f6eac05 100644 --- a/lib/pages/chat_list/chat_list_view.dart +++ b/lib/pages/chat_list/chat_list_view.dart @@ -2,14 +2,11 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; -import 'package:fluffychat/config/app_config.dart'; -import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/pages/chat_list/chat_list.dart'; import 'package:fluffychat/pangea/chat_list/widgets/chat_list_view_body_wrapper.dart'; import 'package:fluffychat/pangea/onboarding/onboarding.dart'; import 'package:fluffychat/pangea/onboarding/onboarding_steps_enum.dart'; -import 'package:fluffychat/widgets/navigation_rail.dart'; class ChatListView extends StatelessWidget { final ChatListController controller; @@ -33,21 +30,20 @@ class ChatListView extends StatelessWidget { }, child: Row( children: [ - if (FluffyThemes.isColumnMode(context) || - AppConfig.displayNavigationRail) ...[ - SpacesNavigationRail( - activeSpaceId: controller.activeSpaceId, - onGoToChats: controller.clearActiveSpace, - onGoToSpaceId: controller.setActiveSpace, - // #Pangea - clearActiveSpace: controller.clearActiveSpace, - // Pangea# - ), - Container( - color: Theme.of(context).dividerColor, - width: 1, - ), - ], + // #Pangea + // if (FluffyThemes.isColumnMode(context) || + // AppConfig.displayNavigationRail) ...[ + // SpacesNavigationRail( + // activeSpaceId: controller.activeSpaceId, + // onGoToChats: controller.clearActiveSpace, + // onGoToSpaceId: controller.setActiveSpace, + // ), + // Container( + // color: Theme.of(context).dividerColor, + // width: 1, + // ), + // ], + // Pangea# Expanded( child: GestureDetector( onTap: FocusManager.instance.primaryFocus?.unfocus, diff --git a/lib/pages/chat_list/client_chooser_button.dart b/lib/pages/chat_list/client_chooser_button.dart index a9b13e731..643a6055c 100644 --- a/lib/pages/chat_list/client_chooser_button.dart +++ b/lib/pages/chat_list/client_chooser_button.dart @@ -1,231 +1,233 @@ -import 'package:flutter/material.dart'; +// #Pangea +// import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; -import 'package:matrix/matrix.dart'; +// import 'package:go_router/go_router.dart'; +// import 'package:matrix/matrix.dart'; -import 'package:fluffychat/config/themes.dart'; -import 'package:fluffychat/l10n/l10n.dart'; -import 'package:fluffychat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart'; -import 'package:fluffychat/widgets/avatar.dart'; -import 'package:fluffychat/widgets/matrix.dart'; -import '../../utils/fluffy_share.dart'; -import 'chat_list.dart'; +// import 'package:fluffychat/config/themes.dart'; +// import 'package:fluffychat/l10n/l10n.dart'; +// import 'package:fluffychat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart'; +// import 'package:fluffychat/widgets/avatar.dart'; +// import 'package:fluffychat/widgets/matrix.dart'; +// import '../../utils/fluffy_share.dart'; +// import 'chat_list.dart'; -class ClientChooserButton extends StatelessWidget { - final ChatListController controller; +// class ClientChooserButton extends StatelessWidget { +// final ChatListController controller; - const ClientChooserButton(this.controller, {super.key}); +// const ClientChooserButton(this.controller, {super.key}); - List> _bundleMenuItems(BuildContext context) { - final matrix = Matrix.of(context); - final bundles = matrix.accountBundles.keys.toList() - ..sort( - (a, b) => a!.isValidMatrixId == b!.isValidMatrixId - ? 0 - : a.isValidMatrixId && !b.isValidMatrixId - ? -1 - : 1, - ); - return >[ - PopupMenuItem( - value: SettingsAction.newGroup, - child: Row( - children: [ - const Icon(Icons.group_add_outlined), - const SizedBox(width: 18), - Text(L10n.of(context).createGroup), - ], - ), - ), - PopupMenuItem( - value: SettingsAction.setStatus, - child: Row( - children: [ - const Icon(Icons.edit_outlined), - const SizedBox(width: 18), - Text(L10n.of(context).setStatus), - ], - ), - ), - PopupMenuItem( - value: SettingsAction.invite, - child: Row( - children: [ - Icon(Icons.adaptive.share_outlined), - const SizedBox(width: 18), - Text(L10n.of(context).inviteContact), - ], - ), - ), - PopupMenuItem( - value: SettingsAction.archive, - child: Row( - children: [ - const Icon(Icons.archive_outlined), - const SizedBox(width: 18), - Text(L10n.of(context).archive), - ], - ), - ), - PopupMenuItem( - value: SettingsAction.settings, - child: Row( - children: [ - const Icon(Icons.settings_outlined), - const SizedBox(width: 18), - Text(L10n.of(context).settings), - ], - ), - ), - const PopupMenuDivider(), - for (final bundle in bundles) ...[ - if (matrix.accountBundles[bundle]!.length != 1 || - matrix.accountBundles[bundle]!.single!.userID != bundle) - PopupMenuItem( - value: null, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - bundle!, - style: TextStyle( - color: Theme.of(context).textTheme.titleMedium!.color, - fontSize: 14, - ), - ), - const Divider(height: 1), - ], - ), - ), - ...matrix.accountBundles[bundle]! - .whereType() - .where((client) => client.isLogged()) - .map( - (client) => PopupMenuItem( - value: client, - child: FutureBuilder( - future: client.fetchOwnProfile(), - builder: (context, snapshot) => Row( - children: [ - Avatar( - mxContent: snapshot.data?.avatarUrl, - name: snapshot.data?.displayName ?? - client.userID!.localpart, - size: 32, - ), - const SizedBox(width: 12), - Expanded( - child: Text( - snapshot.data?.displayName ?? - client.userID!.localpart!, - overflow: TextOverflow.ellipsis, - ), - ), - const SizedBox(width: 12), - IconButton( - icon: const Icon(Icons.edit_outlined), - onPressed: () => controller.editBundlesForAccount( - client.userID, - bundle, - ), - ), - ], - ), - ), - ), - ), - ], - PopupMenuItem( - value: SettingsAction.addAccount, - child: Row( - children: [ - const Icon(Icons.person_add_outlined), - const SizedBox(width: 18), - Text(L10n.of(context).addAccount), - ], - ), - ), - ]; - } +// List> _bundleMenuItems(BuildContext context) { +// final matrix = Matrix.of(context); +// final bundles = matrix.accountBundles.keys.toList() +// ..sort( +// (a, b) => a!.isValidMatrixId == b!.isValidMatrixId +// ? 0 +// : a.isValidMatrixId && !b.isValidMatrixId +// ? -1 +// : 1, +// ); +// return >[ +// PopupMenuItem( +// value: SettingsAction.newGroup, +// child: Row( +// children: [ +// const Icon(Icons.group_add_outlined), +// const SizedBox(width: 18), +// Text(L10n.of(context).createGroup), +// ], +// ), +// ), +// PopupMenuItem( +// value: SettingsAction.setStatus, +// child: Row( +// children: [ +// const Icon(Icons.edit_outlined), +// const SizedBox(width: 18), +// Text(L10n.of(context).setStatus), +// ], +// ), +// ), +// PopupMenuItem( +// value: SettingsAction.invite, +// child: Row( +// children: [ +// Icon(Icons.adaptive.share_outlined), +// const SizedBox(width: 18), +// Text(L10n.of(context).inviteContact), +// ], +// ), +// ), +// PopupMenuItem( +// value: SettingsAction.archive, +// child: Row( +// children: [ +// const Icon(Icons.archive_outlined), +// const SizedBox(width: 18), +// Text(L10n.of(context).archive), +// ], +// ), +// ), +// PopupMenuItem( +// value: SettingsAction.settings, +// child: Row( +// children: [ +// const Icon(Icons.settings_outlined), +// const SizedBox(width: 18), +// Text(L10n.of(context).settings), +// ], +// ), +// ), +// const PopupMenuDivider(), +// for (final bundle in bundles) ...[ +// if (matrix.accountBundles[bundle]!.length != 1 || +// matrix.accountBundles[bundle]!.single!.userID != bundle) +// PopupMenuItem( +// value: null, +// child: Column( +// crossAxisAlignment: CrossAxisAlignment.start, +// mainAxisSize: MainAxisSize.min, +// children: [ +// Text( +// bundle!, +// style: TextStyle( +// color: Theme.of(context).textTheme.titleMedium!.color, +// fontSize: 14, +// ), +// ), +// const Divider(height: 1), +// ], +// ), +// ), +// ...matrix.accountBundles[bundle]! +// .whereType() +// .where((client) => client.isLogged()) +// .map( +// (client) => PopupMenuItem( +// value: client, +// child: FutureBuilder( +// future: client.fetchOwnProfile(), +// builder: (context, snapshot) => Row( +// children: [ +// Avatar( +// mxContent: snapshot.data?.avatarUrl, +// name: snapshot.data?.displayName ?? +// client.userID!.localpart, +// size: 32, +// ), +// const SizedBox(width: 12), +// Expanded( +// child: Text( +// snapshot.data?.displayName ?? +// client.userID!.localpart!, +// overflow: TextOverflow.ellipsis, +// ), +// ), +// const SizedBox(width: 12), +// IconButton( +// icon: const Icon(Icons.edit_outlined), +// onPressed: () => controller.editBundlesForAccount( +// client.userID, +// bundle, +// ), +// ), +// ], +// ), +// ), +// ), +// ), +// ], +// PopupMenuItem( +// value: SettingsAction.addAccount, +// child: Row( +// children: [ +// const Icon(Icons.person_add_outlined), +// const SizedBox(width: 18), +// Text(L10n.of(context).addAccount), +// ], +// ), +// ), +// ]; +// } - @override - Widget build(BuildContext context) { - final matrix = Matrix.of(context); +// @override +// Widget build(BuildContext context) { +// final matrix = Matrix.of(context); - var clientCount = 0; - matrix.accountBundles.forEach((key, value) => clientCount += value.length); - return FutureBuilder( - future: matrix.client.isLogged() ? matrix.client.fetchOwnProfile() : null, - builder: (context, snapshot) => Material( - clipBehavior: Clip.hardEdge, - borderRadius: BorderRadius.circular(99), - color: Colors.transparent, - child: PopupMenuButton( - popUpAnimationStyle: FluffyThemes.isColumnMode(context) - ? AnimationStyle.noAnimation - : null, // https://github.com/flutter/flutter/issues/167180 - onSelected: (o) => _clientSelected(o, context), - itemBuilder: _bundleMenuItems, - child: Center( - child: Avatar( - mxContent: snapshot.data?.avatarUrl, - name: - snapshot.data?.displayName ?? matrix.client.userID?.localpart, - size: 32, - ), - ), - ), - ), - ); - } +// var clientCount = 0; +// matrix.accountBundles.forEach((key, value) => clientCount += value.length); +// return FutureBuilder( +// future: matrix.client.isLogged() ? matrix.client.fetchOwnProfile() : null, +// builder: (context, snapshot) => Material( +// clipBehavior: Clip.hardEdge, +// borderRadius: BorderRadius.circular(99), +// color: Colors.transparent, +// child: PopupMenuButton( +// popUpAnimationStyle: FluffyThemes.isColumnMode(context) +// ? AnimationStyle.noAnimation +// : null, // https://github.com/flutter/flutter/issues/167180 +// onSelected: (o) => _clientSelected(o, context), +// itemBuilder: _bundleMenuItems, +// child: Center( +// child: Avatar( +// mxContent: snapshot.data?.avatarUrl, +// name: +// snapshot.data?.displayName ?? matrix.client.userID?.localpart, +// size: 32, +// ), +// ), +// ), +// ), +// ); +// } - void _clientSelected( - Object object, - BuildContext context, - ) async { - if (object is Client) { - controller.setActiveClient(object); - } else if (object is String) { - controller.setActiveBundle(object); - } else if (object is SettingsAction) { - switch (object) { - case SettingsAction.addAccount: - final consent = await showOkCancelAlertDialog( - context: context, - title: L10n.of(context).addAccount, - message: L10n.of(context).enableMultiAccounts, - okLabel: L10n.of(context).next, - cancelLabel: L10n.of(context).cancel, - ); - if (consent != OkCancelResult.ok) return; - context.go('/rooms/settings/addaccount'); - break; - case SettingsAction.newGroup: - context.go('/rooms/newgroup'); - break; - case SettingsAction.invite: - FluffyShare.shareInviteLink(context); - break; - case SettingsAction.settings: - context.go('/rooms/settings'); - break; - case SettingsAction.archive: - context.go('/rooms/archive'); - break; - case SettingsAction.setStatus: - controller.setStatus(); - break; - } - } - } -} +// void _clientSelected( +// Object object, +// BuildContext context, +// ) async { +// if (object is Client) { +// controller.setActiveClient(object); +// } else if (object is String) { +// controller.setActiveBundle(object); +// } else if (object is SettingsAction) { +// switch (object) { +// case SettingsAction.addAccount: +// final consent = await showOkCancelAlertDialog( +// context: context, +// title: L10n.of(context).addAccount, +// message: L10n.of(context).enableMultiAccounts, +// okLabel: L10n.of(context).next, +// cancelLabel: L10n.of(context).cancel, +// ); +// if (consent != OkCancelResult.ok) return; +// context.go('/rooms/settings/addaccount'); +// break; +// case SettingsAction.newGroup: +// context.go('/rooms/newgroup'); +// break; +// case SettingsAction.invite: +// FluffyShare.shareInviteLink(context); +// break; +// case SettingsAction.settings: +// context.go('/rooms/settings'); +// break; +// case SettingsAction.archive: +// context.go('/rooms/archive'); +// break; +// case SettingsAction.setStatus: +// controller.setStatus(); +// break; +// } +// } +// } +// } -enum SettingsAction { - addAccount, - newGroup, - setStatus, - invite, - settings, - archive, -} +// enum SettingsAction { +// addAccount, +// newGroup, +// setStatus, +// invite, +// settings, +// archive, +// } +// Pangea# diff --git a/lib/pages/chat_list/space_view.dart b/lib/pages/chat_list/space_view.dart index fe5c5e7db..294bad50e 100644 --- a/lib/pages/chat_list/space_view.dart +++ b/lib/pages/chat_list/space_view.dart @@ -1,5 +1,3 @@ -import 'dart:async'; - import 'package:flutter/material.dart'; import 'package:collection/collection.dart'; @@ -10,22 +8,14 @@ import 'package:matrix/matrix.dart'; import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/l10n/l10n.dart'; -import 'package:fluffychat/pages/chat_list/chat_list.dart'; import 'package:fluffychat/pages/chat_list/chat_list_item.dart'; import 'package:fluffychat/pages/chat_list/search_title.dart'; -import 'package:fluffychat/pangea/chat_settings/constants/pangea_room_types.dart'; -import 'package:fluffychat/pangea/chat_settings/widgets/delete_space_dialog.dart'; -import 'package:fluffychat/pangea/common/utils/error_handler.dart'; -import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; -import 'package:fluffychat/pangea/public_spaces/public_room_bottom_sheet.dart'; -import 'package:fluffychat/pangea/space_analytics/analytics_request_indicator.dart'; -import 'package:fluffychat/pangea/spaces/constants/space_constants.dart'; -import 'package:fluffychat/pangea/spaces/widgets/knocking_users_indicator.dart'; -import 'package:fluffychat/pangea/spaces/widgets/leaderboard_participant_list.dart'; -import 'package:fluffychat/pangea/spaces/widgets/space_view_appbar.dart'; import 'package:fluffychat/utils/localized_exception_extension.dart'; import 'package:fluffychat/utils/stream_extension.dart'; +import 'package:fluffychat/widgets/adaptive_dialogs/public_room_dialog.dart'; +import 'package:fluffychat/widgets/adaptive_dialogs/show_modal_action_popup.dart'; import 'package:fluffychat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart'; +import 'package:fluffychat/widgets/adaptive_dialogs/show_text_input_dialog.dart'; import 'package:fluffychat/widgets/avatar.dart'; import 'package:fluffychat/widgets/future_loading_dialog.dart'; import 'package:fluffychat/widgets/matrix.dart'; @@ -39,9 +29,6 @@ class SpaceView extends StatefulWidget { final void Function(Room room) onChatTab; final void Function(Room room, BuildContext context) onChatContext; final String? activeChat; - // #Pangea - final ChatListController controller; - // Pangea# const SpaceView({ required this.spaceId, @@ -50,9 +37,6 @@ class SpaceView extends StatefulWidget { required this.activeChat, required this.toParentSpace, required this.onChatContext, - // #Pangea - required this.controller, - // Pangea# super.key, }); @@ -61,11 +45,7 @@ class SpaceView extends StatefulWidget { } class _SpaceViewState extends State { - // #Pangea - // final List _discoveredChildren = []; - List? _discoveredChildren; - StreamSubscription? _roomSubscription; - // Pangea# + final List _discoveredChildren = []; final TextEditingController _filterController = TextEditingController(); String? _nextBatch; bool _noMoreRooms = false; @@ -73,106 +53,11 @@ class _SpaceViewState extends State { @override void initState() { - // #Pangea - // loadHierarchy(); - // load full participant list into memory to ensure widgets - // that rely on full participants list work as expected - final room = Matrix.of(context).client.getRoomById(widget.spaceId); - room?.requestParticipants().then((_) { - if (mounted) setState(() {}); - }); - - // If, on launch, this room has had updates to its children, - // ensure the hierarchy is properly reloaded - final bool hasUpdate = widget.controller.hasUpdates.contains( - widget.spaceId, - ); - - loadHierarchy(hasUpdate: hasUpdate).then( - // remove this space ID from the set of space IDs with updates - (_) => widget.controller.hasUpdates.remove( - widget.controller.activeSpaceId, - ), - ); - - // Listen for changes to the activeSpace's hierarchy, - // and reload the hierarchy when they come through - final client = Matrix.of(context).client; - _roomSubscription ??= client.onSync.stream - .where(_hasHierarchyUpdate) - .listen((update) => loadHierarchy(hasUpdate: true)); - // Pangea# + _loadHierarchy(); super.initState(); } - // #Pangea - @override - void didUpdateWidget(covariant SpaceView oldWidget) { - // initState doesn't re-run when navigating between spaces - // via the navigation rail, so this accounts for that - super.didUpdateWidget(oldWidget); - if (oldWidget.spaceId != widget.spaceId) { - _discoveredChildren = null; - _nextBatch = null; - _noMoreRooms = false; - - loadHierarchy(hasUpdate: true).then( - // remove this space ID from the set of space IDs with updates - (_) { - if (widget.controller.hasUpdates.contains(widget.spaceId)) { - widget.controller.hasUpdates.remove( - widget.controller.activeSpaceId, - ); - } - }); - } - } - - @override - void dispose() { - _roomSubscription?.cancel(); - super.dispose(); - } - - Future _joinDefaultChats() async { - if (_discoveredChildren == null) return; - final found = List.from(_discoveredChildren!); - - final List joinFutures = []; - for (final chunk in found) { - if (chunk.canonicalAlias == null) continue; - final alias = chunk.canonicalAlias!; - - final isDefaultChat = (alias.localpart ?? '') - .startsWith(SpaceConstants.announcementsChatAlias) || - (alias.localpart ?? '') - .startsWith(SpaceConstants.introductionChatAlias); - - if (!isDefaultChat) continue; - - joinFutures.add( - Matrix.of(context).client.joinRoom(alias).then((_) { - _discoveredChildren?.remove(chunk); - }).catchError((e, s) { - ErrorHandler.logError( - e: e, - s: s, - data: { - 'alias': alias, - 'spaceId': widget.spaceId, - }, - ); - return null; - }), - ); - } - - if (joinFutures.isNotEmpty) { - await Future.wait(joinFutures); - } - } - - Future loadHierarchy({hasUpdate = false}) async { + void _loadHierarchy() async { final room = Matrix.of(context).client.getRoomById(widget.spaceId); if (room == null) return; @@ -181,166 +66,53 @@ class _SpaceViewState extends State { }); try { - await _loadHierarchy(activeSpace: room, hasUpdate: hasUpdate); - await _joinDefaultChats(); + final hierarchy = await room.client.getSpaceHierarchy( + widget.spaceId, + suggestedOnly: false, + maxDepth: 2, + from: _nextBatch, + ); + if (!mounted) return; + setState(() { + _nextBatch = hierarchy.nextBatch; + if (hierarchy.nextBatch == null) { + _noMoreRooms = true; + } + _discoveredChildren.addAll( + hierarchy.rooms + .where((c) => room.client.getRoomById(c.roomId) == null), + ); + _isLoading = false; + }); } catch (e, s) { Logs().w('Unable to load hierarchy', e, s); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(e.toLocalizedString(context))), - ); - } - } finally { - if (mounted) { - setState(() { - _isLoading = false; - }); - } + if (!mounted) return; + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(e.toLocalizedString(context)))); + setState(() { + _isLoading = false; + }); } } - /// Internal logic of loadHierarchy. It will load the hierarchy of - /// the active space id (or specified spaceId). - Future _loadHierarchy({ - required Room activeSpace, - bool hasUpdate = false, - }) async { - // Load all of the space's state events. Space Child events - // are used to filtering out unsuggested, unjoined rooms. - await activeSpace.postLoad(); - - // The current number of rooms loaded for this space that are visible in the UI - final int prevLength = !hasUpdate ? (_discoveredChildren?.length ?? 0) : 0; - - // Failsafe to prevent too many calls to the server in a row - int callsToServer = 0; - - List? currentHierarchy = - _discoveredChildren == null || hasUpdate - ? null - : List.from(_discoveredChildren!); - String? currentNextBatch = hasUpdate ? null : _nextBatch; - - // Makes repeated calls to the server until 10 new visible rooms have - // been loaded, or there are no rooms left to load. Using a loop here, - // rather than one single call to the endpoint, because some spaces have - // so many invisible rooms (analytics rooms) that it might look like - // pressing the 'load more' button does nothing (Because the only rooms - // coming through from those calls are analytics rooms). - while (callsToServer < 5) { - // if this space has been loaded and there are no more rooms to load, break - if (currentHierarchy != null && currentNextBatch == null) { - break; - } - - // if this space has been loaded and 10 new rooms have been loaded, break - final int currentLength = currentHierarchy?.length ?? 0; - if (currentLength - prevLength >= 10) { - break; - } - - // make the call to the server - final response = await Matrix.of(context).client.getSpaceHierarchy( - widget.spaceId, - maxDepth: 1, - from: currentNextBatch, - limit: 100, - ); - callsToServer++; - - if (response.nextBatch == null) { - _noMoreRooms = true; - } - - // if rooms have earlier been loaded for this space, add those - // previously loaded rooms to the front of the response list - response.rooms.insertAll( - 0, - currentHierarchy ?? [], - ); - - // finally, set the response to the last response for this space - // and set the current next batch token - currentHierarchy = _filterHierarchyResponse(activeSpace, response.rooms); - currentNextBatch = response.nextBatch; - } - - _discoveredChildren = currentHierarchy; - _discoveredChildren?.sort(_sortSpaceChildren); - _nextBatch = currentNextBatch; - } - - // void _loadHierarchy() async { - // final room = Matrix.of(context).client.getRoomById(widget.spaceId); - // if (room == null) return; - - // setState(() { - // _isLoading = true; - // }); - - // try { - // final hierarchy = await room.client.getSpaceHierarchy( - // widget.spaceId, - // suggestedOnly: false, - // maxDepth: 2, - // from: _nextBatch, - // ); - // if (!mounted) return; - // setState(() { - // _nextBatch = hierarchy.nextBatch; - // if (hierarchy.nextBatch == null) { - // _noMoreRooms = true; - // } - // _discoveredChildren.addAll( - // hierarchy.rooms - // .where((c) => room.client.getRoomById(c.roomId) == null), - // ); - // _isLoading = false; - // }); - // } catch (e, s) { - // Logs().w('Unable to load hierarchy', e, s); - // if (!mounted) return; - // ScaffoldMessenger.of(context) - // .showSnackBar(SnackBar(content: Text(e.toLocalizedString(context)))); - // setState(() { - // _isLoading = false; - // }); - // } - // } - // Pangea# - void _joinChildRoom(SpaceRoomsChunk item) async { final client = Matrix.of(context).client; final space = client.getRoomById(widget.spaceId); - // #Pangea - // final joined = await showAdaptiveDialog( - // context: context, - // builder: (_) => PublicRoomDialog( - // chunk: item, - // via: space?.spaceChildren - // .firstWhereOrNull( - // (child) => child.roomId == item.roomId, - // ) - // ?.via, - // ), - // ); - final joined = await PublicRoomBottomSheet.show( + final joined = await showAdaptiveDialog( context: context, - chunk: item, - via: space?.spaceChildren - .firstWhereOrNull( - (child) => child.roomId == item.roomId, - ) - ?.via, + builder: (_) => PublicRoomDialog( + chunk: item, + via: space?.spaceChildren + .firstWhereOrNull( + (child) => child.roomId == item.roomId, + ) + ?.via, + ), ); - // Pangea# if (mounted && joined == true) { setState(() { - // #Pangea - // _discoveredChildren.remove(item); - _discoveredChildren?.remove(item); - // Pangea# + _discoveredChildren.remove(item); }); } } @@ -361,10 +133,7 @@ class _SpaceViewState extends State { final confirmed = await showOkCancelAlertDialog( context: context, title: L10n.of(context).areYouSure, - // #Pangea - // message: L10n.of(context).archiveRoomDescription, - message: L10n.of(context).leaveSpaceDescription, - // Pangea# + message: L10n.of(context).archiveRoomDescription, okLabel: L10n.of(context).leave, cancelLabel: L10n.of(context).cancel, isDestructive: true, @@ -374,315 +143,176 @@ class _SpaceViewState extends State { final success = await showFutureLoadingDialog( context: context, - // #Pangea - // future: () async => await space?.leave(), - future: () async => await space?.leaveSpace(), - // Pangea# + future: () async => await space?.leave(), ); if (!mounted) return; if (success.error != null) return; widget.onBack(); - // #Pangea - case SpaceActions.delete: - if (space == null) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(L10n.of(context).oopsSomethingWentWrong)), - ); - return; - } - final resp = await showDialog( - context: context, - builder: (_) => DeleteSpaceDialog(space: space), - ); - - if (resp == true) { - context.go("/rooms?spaceId=clear"); - } - break; - case SpaceActions.groupChat: - context.go("/rooms/newgroup?space=${widget.spaceId}"); - break; - case SpaceActions.subspace: - space?.addSubspace(context); - break; - // Pangea# } } - // #Pangea - // void _addChatOrSubspace() async { - // final roomType = await showModalActionPopup( - // context: context, - // title: L10n.of(context).addChatOrSubSpace, - // actions: [ - // AdaptiveModalAction( - // value: AddRoomType.subspace, - // label: L10n.of(context).createNewSpace, - // ), - // AdaptiveModalAction( - // value: AddRoomType.chat, - // label: L10n.of(context).createGroup, - // ), - // ], - // ); - // if (roomType == null) return; - - // final names = await showTextInputDialog( - // context: context, - // title: roomType == AddRoomType.subspace - // ? L10n.of(context).createNewSpace - // : L10n.of(context).createGroup, - // hintText: roomType == AddRoomType.subspace - // ? L10n.of(context).spaceName - // : L10n.of(context).groupName, - // minLines: 1, - // maxLines: 1, - // maxLength: 64, - // validator: (text) { - // if (text.isEmpty) { - // return L10n.of(context).pleaseChoose; - // } - // return null; - // }, - // okLabel: L10n.of(context).create, - // cancelLabel: L10n.of(context).cancel, - // ); - // if (names == null) return; - // final client = Matrix.of(context).client; - // final result = await showFutureLoadingDialog( - // context: context, - // future: () async { - // late final String roomId; - // final activeSpace = client.getRoomById(widget.spaceId)!; - // await activeSpace.postLoad(); - - // if (roomType == AddRoomType.subspace) { - // roomId = await client.createSpace( - // name: names, - // visibility: activeSpace.joinRules == JoinRules.public - // ? sdk.Visibility.public - // : sdk.Visibility.private, - // ); - // } else { - // roomId = await client.createGroupChat( - // groupName: names, - // preset: activeSpace.joinRules == JoinRules.public - // ? CreateRoomPreset.publicChat - // : CreateRoomPreset.privateChat, - // visibility: activeSpace.joinRules == JoinRules.public - // ? sdk.Visibility.public - // : sdk.Visibility.private, - // ); - // } - // await activeSpace.setSpaceChild(roomId); - // }, - // ); - // if (result.error != null) return; - // } - // Pangea# - - // #Pangea - bool _includeSpaceChild( - Room space, - SpaceRoomsChunk hierarchyMember, - ) { - if (!mounted) return false; - final bool isAnalyticsRoom = - hierarchyMember.roomType == PangeaRoomTypes.analytics; - - final bool isMember = [Membership.join, Membership.invite].contains( - Matrix.of(context).client.getRoomById(hierarchyMember.roomId)?.membership, + void _addChatOrSubspace() async { + final roomType = await showModalActionPopup( + context: context, + title: L10n.of(context).addChatOrSubSpace, + actions: [ + AdaptiveModalAction( + value: AddRoomType.subspace, + label: L10n.of(context).createNewSpace, + ), + AdaptiveModalAction( + value: AddRoomType.chat, + label: L10n.of(context).createGroup, + ), + ], ); + if (roomType == null) return; - final bool isSuggested = - space.spaceChildSuggestionStatus[hierarchyMember.roomId] ?? true; + final names = await showTextInputDialog( + context: context, + title: roomType == AddRoomType.subspace + ? L10n.of(context).createNewSpace + : L10n.of(context).createGroup, + hintText: roomType == AddRoomType.subspace + ? L10n.of(context).spaceName + : L10n.of(context).groupName, + minLines: 1, + maxLines: 1, + maxLength: 64, + validator: (text) { + if (text.isEmpty) { + return L10n.of(context).pleaseChoose; + } + return null; + }, + okLabel: L10n.of(context).create, + cancelLabel: L10n.of(context).cancel, + ); + if (names == null) return; + final client = Matrix.of(context).client; + final result = await showFutureLoadingDialog( + context: context, + future: () async { + late final String roomId; + final activeSpace = client.getRoomById(widget.spaceId)!; + await activeSpace.postLoad(); - return !isAnalyticsRoom && (isMember || isSuggested); + if (roomType == AddRoomType.subspace) { + roomId = await client.createSpace( + name: names, + visibility: activeSpace.joinRules == JoinRules.public + ? sdk.Visibility.public + : sdk.Visibility.private, + ); + } else { + roomId = await client.createGroupChat( + groupName: names, + preset: activeSpace.joinRules == JoinRules.public + ? CreateRoomPreset.publicChat + : CreateRoomPreset.privateChat, + visibility: activeSpace.joinRules == JoinRules.public + ? sdk.Visibility.public + : sdk.Visibility.private, + ); + } + await activeSpace.setSpaceChild(roomId); + }, + ); + if (result.error != null) return; } - List _filterHierarchyResponse( - Room space, - List hierarchyResponse, - ) { - final List filteredChildren = []; - for (final child in hierarchyResponse) { - if (child.roomId == widget.spaceId) { - continue; - } - - final room = Matrix.of(context).client.getRoomById(child.roomId); - if (room != null && room.membership != Membership.leave) { - // If the room is already joined or invited, skip it - continue; - } - - final isDuplicate = filteredChildren.any( - (filtered) => filtered.roomId == child.roomId, - ); - if (isDuplicate) continue; - - if (_includeSpaceChild(space, child)) { - filteredChildren.add(child); - } - } - return filteredChildren; - } - - /// Used to filter out sync updates with hierarchy updates for the active - /// space so that the view can be auto-reloaded in the room subscription - bool _hasHierarchyUpdate(SyncUpdate update) { - final joinTimeline = update.rooms?.join?[widget.spaceId]?.timeline; - final leaveTimeline = update.rooms?.leave?[widget.spaceId]?.timeline; - if (joinTimeline == null && leaveTimeline == null) return false; - final bool hasJoinUpdate = joinTimeline?.events?.any( - (event) => event.type == EventTypes.SpaceChild, - ) ?? - false; - final bool hasLeaveUpdate = leaveTimeline?.events?.any( - (event) => event.type == EventTypes.SpaceChild, - ) ?? - false; - return hasJoinUpdate || hasLeaveUpdate; - } - - int _sortSpaceChildren( - SpaceRoomsChunk a, - SpaceRoomsChunk b, - ) { - final bool aIsSpace = a.roomType == 'm.space'; - final bool bIsSpace = b.roomType == 'm.space'; - - if (aIsSpace && !bIsSpace) { - return -1; - } else if (!aIsSpace && bIsSpace) { - return 1; - } - return 0; - } - // Pangea# - @override Widget build(BuildContext context) { final theme = Theme.of(context); final room = Matrix.of(context).client.getRoomById(widget.spaceId); - // #Pangea - // final displayname = - // room?.getLocalizedDisplayname() ?? L10n.of(context).nothingFound; - // Pangea# - - // #Pangea - final joinedParents = room?.spaceParents - .map((parent) { - final roomId = parent.roomId; - if (roomId == null) return null; - return room.client.getRoomById(roomId); - }) - .whereType() - .toList(); - // Pangea# - + final displayname = + room?.getLocalizedDisplayname() ?? L10n.of(context).nothingFound; return Scaffold( - appBar: PreferredSize( - preferredSize: const Size.fromHeight(60.0), - child: SpaceViewAppbar( - onSpaceAction: _onSpaceAction, - onBack: widget.onBack, - room: room, - toParentSpace: widget.toParentSpace, - joinedParents: joinedParents, + appBar: AppBar( + leading: FluffyThemes.isColumnMode(context) + ? null + : Center( + child: CloseButton( + onPressed: widget.onBack, + ), + ), + automaticallyImplyLeading: false, + titleSpacing: FluffyThemes.isColumnMode(context) ? null : 0, + title: ListTile( + contentPadding: EdgeInsets.zero, + leading: Avatar( + mxContent: room?.avatar, + name: displayname, + border: BorderSide(width: 1, color: theme.dividerColor), + borderRadius: BorderRadius.circular(AppConfig.borderRadius / 2), + ), + title: Text( + displayname, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: room == null + ? null + : Text( + L10n.of(context).countChatsAndCountParticipants( + room.spaceChildren.length, + room.summary.mJoinedMemberCount ?? 1, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), ), + actions: [ + PopupMenuButton( + useRootNavigator: true, + onSelected: _onSpaceAction, + itemBuilder: (context) => [ + PopupMenuItem( + value: SpaceActions.settings, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.settings_outlined), + const SizedBox(width: 12), + Text(L10n.of(context).settings), + ], + ), + ), + PopupMenuItem( + value: SpaceActions.invite, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.person_add_outlined), + const SizedBox(width: 12), + Text(L10n.of(context).invite), + ], + ), + ), + PopupMenuItem( + value: SpaceActions.leave, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.delete_outlined), + const SizedBox(width: 12), + Text(L10n.of(context).leave), + ], + ), + ), + ], + ), + ], ), - // appBar: AppBar( - // leading: FluffyThemes.isColumnMode(context) - // ? null - // : Center( - // child: CloseButton( - // onPressed: widget.onBack, - // ), - // ), - // automaticallyImplyLeading: false, - // titleSpacing: FluffyThemes.isColumnMode(context) ? null : 0, - // title: ListTile( - // contentPadding: EdgeInsets.zero, - // leading: Avatar( - // mxContent: room?.avatar, - // name: displayname, - // border: BorderSide(width: 1, color: theme.dividerColor), - // borderRadius: BorderRadius.circular(AppConfig.borderRadius / 2), - // ), - // title: Text( - // displayname, - // maxLines: 1, - // overflow: TextOverflow.ellipsis, - // ), - // subtitle: room == null - // ? null - // : Text( - // L10n.of(context).countChatsAndCountParticipants( - // room.spaceChildren.length, - // room.summary.mJoinedMemberCount ?? 1, - // ), - // maxLines: 1, - // overflow: TextOverflow.ellipsis, - // ), - // ), - // actions: [ - // PopupMenuButton( - // useRootNavigator: true, - // onSelected: _onSpaceAction, - // itemBuilder: (context) => [ - // PopupMenuItem( - // value: SpaceActions.settings, - // child: Row( - // mainAxisSize: MainAxisSize.min, - // children: [ - // const Icon(Icons.settings_outlined), - // const SizedBox(width: 12), - // Text(L10n.of(context).settings), - // ], - // ), - // ), - // PopupMenuItem( - // value: SpaceActions.invite, - // child: Row( - // mainAxisSize: MainAxisSize.min, - // children: [ - // const Icon(Icons.person_add_outlined), - // const SizedBox(width: 12), - // Text(L10n.of(context).invite), - // ], - // ), - // ), - // PopupMenuItem( - // value: SpaceActions.leave, - // child: Row( - // mainAxisSize: MainAxisSize.min, - // children: [ - // const Icon(Icons.delete_outlined), - // const SizedBox(width: 12), - // Text(L10n.of(context).leave), - // ], - // ), - // ), - // ], - // ), - // ], - // ), floatingActionButton: room?.canChangeStateEvent( EventTypes.SpaceChild, ) == true ? FloatingActionButton.extended( - // #Pangea - // onPressed: _addChatOrSubspace, - // label: Text(L10n.of(context).group), - // icon: const Icon(Icons.group_add_outlined), - onPressed: () => - context.go("/rooms/${widget.spaceId}/details/planner"), - label: Text(L10n.of(context).activities), - icon: const Icon(Icons.event_note_outlined), - // Pangea# + onPressed: _addChatOrSubspace, + label: Text(L10n.of(context).group), + icon: const Icon(Icons.group_add_outlined), ) : null, body: room == null @@ -704,21 +334,16 @@ class _SpaceViewState extends State { final joinedRooms = room.client.rooms .where((room) => childrenIds.remove(room.id)) - // #Pangea - .where((room) => !room.isHiddenRoom) - // Pangea# .toList(); - // #Pangea - // final joinedParents = room.spaceParents - // .map((parent) { - // final roomId = parent.roomId; - // if (roomId == null) return null; - // return room.client.getRoomById(roomId); - // }) - // .whereType() - // .toList(); - // Pangea# + final joinedParents = room.spaceParents + .map((parent) { + final roomId = parent.roomId; + if (roomId == null) return null; + return room.client.getRoomById(roomId); + }) + .whereType() + .toList(); final filter = _filterController.text.trim().toLowerCase(); return CustomScrollView( slivers: [ @@ -756,63 +381,47 @@ class _SpaceViewState extends State { ), ), ), - // #Pangea - // SliverList.builder( - // itemCount: joinedParents.length, - // itemBuilder: (context, i) { - // final displayname = - // joinedParents[i].getLocalizedDisplayname(); - // return Padding( - // padding: const EdgeInsets.symmetric( - // horizontal: 8, - // vertical: 1, - // ), - // child: Material( - // borderRadius: - // BorderRadius.circular(AppConfig.borderRadius), - // clipBehavior: Clip.hardEdge, - // child: ListTile( - // minVerticalPadding: 0, - // leading: Icon( - // Icons.adaptive.arrow_back_outlined, - // size: 16, - // ), - // title: Row( - // children: [ - // Avatar( - // mxContent: joinedParents[i].avatar, - // name: displayname, - // // #Pangea - // userId: joinedParents[i].directChatMatrixID, - // // Pangea# - // size: Avatar.defaultSize / 2, - // borderRadius: BorderRadius.circular( - // AppConfig.borderRadius / 4, - // ), - // ), - // const SizedBox(width: 8), - // Expanded(child: Text(displayname)), - // ], - // ), - // onTap: () => - // widget.toParentSpace(joinedParents[i].id), - // ), - // ), - // ); - // }, - // ), - KnockingUsersIndicator(room: room), - if (!FluffyThemes.isColumnMode(context)) - SliverList.builder( - itemCount: 1, - itemBuilder: (context, i) { - return LeaderboardParticipantList( - space: room, - ); - }, - ), - AnalyticsRequestIndicator(room: room), - // Pangea# + SliverList.builder( + itemCount: joinedParents.length, + itemBuilder: (context, i) { + final displayname = + joinedParents[i].getLocalizedDisplayname(); + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 1, + ), + child: Material( + borderRadius: + BorderRadius.circular(AppConfig.borderRadius), + clipBehavior: Clip.hardEdge, + child: ListTile( + minVerticalPadding: 0, + leading: Icon( + Icons.adaptive.arrow_back_outlined, + size: 16, + ), + title: Row( + children: [ + Avatar( + mxContent: joinedParents[i].avatar, + name: displayname, + size: Avatar.defaultSize / 2, + borderRadius: BorderRadius.circular( + AppConfig.borderRadius / 4, + ), + ), + const SizedBox(width: 8), + Expanded(child: Text(displayname)), + ], + ), + onTap: () => + widget.toParentSpace(joinedParents[i].id), + ), + ), + ); + }, + ), SliverList.builder( itemCount: joinedRooms.length, itemBuilder: (context, i) { @@ -830,10 +439,7 @@ class _SpaceViewState extends State { }, ), SliverList.builder( - // #Pangea - // itemCount: _discoveredChildren.length + 2, - itemCount: (_discoveredChildren?.length ?? 0) + 2, - // Pangea# + itemCount: _discoveredChildren.length + 2, itemBuilder: (context, i) { if (i == 0) { return SearchTitle( @@ -842,10 +448,7 @@ class _SpaceViewState extends State { ); } i--; - // #Pangea - // if (i == _discoveredChildren.length) { - if (i == (_discoveredChildren?.length ?? 0)) { - // Pangea# + if (i == _discoveredChildren.length) { if (_noMoreRooms) { return Padding( padding: const EdgeInsets.all(12.0), @@ -863,10 +466,7 @@ class _SpaceViewState extends State { vertical: 2.0, ), child: TextButton( - // #Pangea - // onPressed: _isLoading ? null : _loadHierarchy, - onPressed: _isLoading ? null : loadHierarchy, - // Pangea# + onPressed: _isLoading ? null : _loadHierarchy, child: _isLoading ? LinearProgressIndicator( borderRadius: BorderRadius.circular( @@ -877,10 +477,7 @@ class _SpaceViewState extends State { ), ); } - // #Pangea - // final item = _discoveredChildren[i]; - final item = _discoveredChildren![i]; - // Pangea# + final item = _discoveredChildren[i]; final displayname = item.name ?? item.canonicalAlias ?? L10n.of(context).emptyChat; @@ -905,12 +502,6 @@ class _SpaceViewState extends State { leading: Avatar( mxContent: item.avatarUrl, name: displayname, - // #Pangea - userId: Matrix.of(context) - .client - .getRoomById(item.roomId) - ?.directChatMatrixID, - // Pangea# borderRadius: item.roomType == 'm.space' ? BorderRadius.circular( AppConfig.borderRadius / 2, @@ -966,9 +557,4 @@ enum SpaceActions { settings, invite, leave, - // #Pangea - delete, - groupChat, - subspace, - // Pangea# } diff --git a/lib/pages/chat_permissions_settings/chat_permissions_settings.dart b/lib/pages/chat_permissions_settings/chat_permissions_settings.dart index 2e929f534..cc24f2e1e 100644 --- a/lib/pages/chat_permissions_settings/chat_permissions_settings.dart +++ b/lib/pages/chat_permissions_settings/chat_permissions_settings.dart @@ -21,7 +21,14 @@ class ChatPermissionsSettings extends StatefulWidget { } class ChatPermissionsSettingsController extends State { - String? get roomId => GoRouterState.of(context).pathParameters['roomid']; + // #Pangea + // String? get roomId => GoRouterState.of(context).pathParameters['roomid']; + String? get roomId { + final pathParameters = GoRouterState.of(context).pathParameters; + return pathParameters['roomid'] ?? pathParameters['spaceid']; + } + + // Pangea# void editPowerLevel( BuildContext context, String key, diff --git a/lib/pages/new_group/new_group.dart b/lib/pages/new_group/new_group.dart index cb5df6b51..89b19f414 100644 --- a/lib/pages/new_group/new_group.dart +++ b/lib/pages/new_group/new_group.dart @@ -214,7 +214,7 @@ class NewGroupController extends State { GoogleAnalytics.createClass(room.name, spaceCode); } - context.go("/rooms?spaceId=$spaceId"); + context.go("/rooms/spaces/$spaceId/details"); // Pangea# } diff --git a/lib/pages/settings/settings_view.dart b/lib/pages/settings/settings_view.dart index c273109c9..b830b29c4 100644 --- a/lib/pages/settings/settings_view.dart +++ b/lib/pages/settings/settings_view.dart @@ -7,14 +7,12 @@ import 'package:package_info_plus/package_info_plus.dart'; import 'package:url_launcher/url_launcher_string.dart'; import 'package:fluffychat/config/app_config.dart'; -import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/pangea/common/config/environment.dart'; import 'package:fluffychat/utils/fluffy_share.dart'; import 'package:fluffychat/widgets/avatar.dart'; import 'package:fluffychat/widgets/future_loading_dialog.dart'; import 'package:fluffychat/widgets/matrix.dart'; -import 'package:fluffychat/widgets/navigation_rail.dart'; import '../../widgets/mxc_image_viewer.dart'; import 'settings.dart'; @@ -43,19 +41,17 @@ class SettingsView extends StatelessWidget { children: [ // #Pangea // if (FluffyThemes.isColumnMode(context)) ...[ - if (FluffyThemes.isColumnMode(context) || - AppConfig.displayNavigationRail) ...[ - // Pangea# - SpacesNavigationRail( - activeSpaceId: null, - onGoToChats: () => context.go('/rooms'), - onGoToSpaceId: (spaceId) => context.go('/rooms?spaceId=$spaceId'), - ), - Container( - color: Theme.of(context).dividerColor, - width: 1, - ), - ], + // SpacesNavigationRail( + // activeSpaceId: null, + // onGoToChats: () => context.go('/rooms'), + // onGoToSpaceId: (spaceId) => context.go('/rooms?spaceId=$spaceId'), + // ), + // Container( + // color: Theme.of(context).dividerColor, + // width: 1, + // ), + // ], + // Pangea# Expanded( child: Scaffold( // #Pangea diff --git a/lib/pages/settings_emotes/settings_emotes.dart b/lib/pages/settings_emotes/settings_emotes.dart index 0771b5748..cd1db0518 100644 --- a/lib/pages/settings_emotes/settings_emotes.dart +++ b/lib/pages/settings_emotes/settings_emotes.dart @@ -27,7 +27,13 @@ class EmotesSettings extends StatefulWidget { } class EmotesSettingsController extends State { - String? get roomId => GoRouterState.of(context).pathParameters['roomid']; + // #Pangea + // String? get roomId => GoRouterState.of(context).pathParameters['roomid']; + String? get roomId { + final pathParameters = GoRouterState.of(context).pathParameters; + return pathParameters['roomid'] ?? pathParameters['spaceid']; + } + // Pangea# Room? get room => roomId != null ? Matrix.of(context).client.getRoomById(roomId!) : null; diff --git a/lib/pages/settings_multiple_emotes/settings_multiple_emotes.dart b/lib/pages/settings_multiple_emotes/settings_multiple_emotes.dart index 276bc08ad..229a82104 100644 --- a/lib/pages/settings_multiple_emotes/settings_multiple_emotes.dart +++ b/lib/pages/settings_multiple_emotes/settings_multiple_emotes.dart @@ -13,7 +13,14 @@ class MultipleEmotesSettings extends StatefulWidget { } class MultipleEmotesSettingsController extends State { - String? get roomId => GoRouterState.of(context).pathParameters['roomid']; + // #Pangea + // String? get roomId => GoRouterState.of(context).pathParameters['roomid']; + String? get roomId { + final pathParameters = GoRouterState.of(context).pathParameters; + return pathParameters['roomid'] ?? pathParameters['spaceid']; + } + // Pangea# + @override Widget build(BuildContext context) => MultipleEmotesSettingsView(this); } diff --git a/lib/pangea/activity_generator/activity_plan_card.dart b/lib/pangea/activity_generator/activity_plan_card.dart index bd574d305..380e51ffe 100644 --- a/lib/pangea/activity_generator/activity_plan_card.dart +++ b/lib/pangea/activity_generator/activity_plan_card.dart @@ -39,14 +39,14 @@ class ActivityPlanCard extends StatelessWidget { final ids = await controller.launchToSpace(); ids.length == 1 - ? context.go("/rooms/${ids.first}") - : context.go("/rooms?spaceId=${controller.room.id}"); + ? context.go("/rooms/spaces/${controller.room.id}/${ids.first}") + : context.go("/rooms/spaces/${controller.room.id}/details"); Navigator.of(context).pop(); }, ); if (!resp.isError) { - context.go("/rooms?spaceId=${controller.room.id}"); + context.go("/rooms/spaces/${controller.room.id}/details"); } } diff --git a/lib/pangea/activity_planner/activity_planner_builder.dart b/lib/pangea/activity_planner/activity_planner_builder.dart index 6e3f62391..722d949f4 100644 --- a/lib/pangea/activity_planner/activity_planner_builder.dart +++ b/lib/pangea/activity_planner/activity_planner_builder.dart @@ -13,6 +13,7 @@ import 'package:fluffychat/pangea/activity_planner/activity_plan_request.dart'; import 'package:fluffychat/pangea/activity_sessions/activity_room_extension.dart'; import 'package:fluffychat/pangea/activity_suggestions/activity_plan_repo.dart'; import 'package:fluffychat/pangea/chat/constants/default_power_level.dart'; +import 'package:fluffychat/pangea/chat_settings/constants/pangea_room_types.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/extensions/join_rule_extension.dart'; import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; @@ -33,6 +34,9 @@ class ActivityPlannerBuilder extends StatefulWidget { final String? initialFilename; final Room room; + final bool enabledEdits; + final bool enableMultiLaunch; + final Widget Function(ActivityPlannerBuilderState) builder; const ActivityPlannerBuilder({ @@ -41,6 +45,8 @@ class ActivityPlannerBuilder extends StatefulWidget { this.initialFilename, required this.room, required this.builder, + this.enabledEdits = false, + this.enableMultiLaunch = false, }); @override @@ -334,9 +340,13 @@ class ActivityPlannerBuilderState extends State { Future _launchActivityRoom(int index) async { await updateImageURL(); - final roomID = await Matrix.of(context).client.createGroupChat( + final roomID = await Matrix.of(context).client.createRoom( + creationContent: { + 'type': + "${PangeaRoomTypes.activitySession}:${updatedActivity.bookmarkId}", + }, visibility: Visibility.private, - groupName: "${updatedActivity.title} ${index + 1}", + name: "${updatedActivity.title} ${index + 1}", initialState: [ if (imageURL != null) StateEvent( @@ -356,7 +366,6 @@ class ActivityPlannerBuilderState extends State { ], ), ], - enableEncryption: false, ); Room? activityRoom = room.client.getRoomById(roomID); diff --git a/lib/pangea/activity_planner/activity_planner_page.dart b/lib/pangea/activity_planner/activity_planner_page.dart index 9429df245..ebca32ae1 100644 --- a/lib/pangea/activity_planner/activity_planner_page.dart +++ b/lib/pangea/activity_planner/activity_planner_page.dart @@ -137,7 +137,7 @@ class ActivityPlannerPageState extends State { ), selected: false, onSelected: (_) => context.go( - '/rooms/${widget.roomID}/details/planner/generator', + '/rooms/spaces/${widget.roomID}/details/planner/generator', ), ), ], diff --git a/lib/pangea/activity_sessions/activity_finished_status_message.dart b/lib/pangea/activity_sessions/activity_finished_status_message.dart index 7feb2fbcd..9117d2440 100644 --- a/lib/pangea/activity_sessions/activity_finished_status_message.dart +++ b/lib/pangea/activity_sessions/activity_finished_status_message.dart @@ -14,6 +14,8 @@ import 'package:fluffychat/pangea/activity_sessions/activity_role_model.dart'; import 'package:fluffychat/pangea/activity_sessions/activity_room_extension.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; import 'package:fluffychat/pangea/analytics_summary/progress_indicators_enum.dart'; +import 'package:fluffychat/pangea/courses/course_plan_room_extension.dart'; +import 'package:fluffychat/pangea/courses/course_repo.dart'; import 'package:fluffychat/widgets/future_loading_dialog.dart'; import 'package:fluffychat/widgets/matrix.dart'; @@ -28,10 +30,28 @@ class ActivityFinishedStatusMessage extends StatelessWidget { Map get _roles => controller.room.activityPlan?.roles ?? {}; - Future _archiveToAnalytics() async { + Future _archiveToAnalytics(BuildContext context) async { await controller.room.archiveActivity(); await MatrixState.pangeaController.putAnalytics .sendActivityAnalytics(controller.room.id); + + final courseParent = controller.room.courseParent; + if (courseParent?.coursePlan == null) return; + final coursePlan = await CourseRepo.get( + courseParent!.coursePlan!.uuid, + ); + + if (coursePlan == null) { + throw L10n.of(context).noCourseFound; + } + + final activityId = controller.room.activityPlan!.bookmarkId; + final topicId = coursePlan.topicID(activityId); + if (topicId == null) { + throw L10n.of(context).activityNotFoundForCourse; + } + + await courseParent.finishCourseActivity(activityId, topicId); } List get _rolesWithSummaries { @@ -226,7 +246,7 @@ class ActivityFinishedStatusMessage extends StatelessWidget { onPressed: () async { final resp = await showFutureLoadingDialog( context: context, - future: _archiveToAnalytics, + future: () => _archiveToAnalytics(context), ); if (!resp.isError) { diff --git a/lib/pangea/activity_sessions/activity_room_extension.dart b/lib/pangea/activity_sessions/activity_room_extension.dart index 1c6c32d81..33b436a1c 100644 --- a/lib/pangea/activity_sessions/activity_room_extension.dart +++ b/lib/pangea/activity_sessions/activity_room_extension.dart @@ -12,8 +12,10 @@ import 'package:fluffychat/pangea/activity_summary/activity_summary_analytics_mo import 'package:fluffychat/pangea/activity_summary/activity_summary_model.dart'; import 'package:fluffychat/pangea/activity_summary/activity_summary_request_model.dart'; import 'package:fluffychat/pangea/bot/utils/bot_name.dart'; +import 'package:fluffychat/pangea/chat_settings/constants/pangea_room_types.dart'; import 'package:fluffychat/pangea/common/config/environment.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; +import 'package:fluffychat/pangea/courses/course_plan_room_extension.dart'; import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart'; import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart'; import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; @@ -322,4 +324,16 @@ extension ActivityRoomExtension on Room { } bool get isHiddenActivityRoom => ownRole?.isArchived ?? false; + + Room? get courseParent => pangeaSpaceParents.firstWhereOrNull( + (parent) => parent.coursePlan != null, + ); + + bool get isActivitySession => + getState(EventTypes.RoomCreate) + ?.content + .tryGet('type') + ?.startsWith(PangeaRoomTypes.activitySession) == + true || + activityPlan != null; } diff --git a/lib/pangea/activity_suggestions/activity_suggestion_card.dart b/lib/pangea/activity_suggestions/activity_suggestion_card.dart index 8d40b8321..2786cb4c6 100644 --- a/lib/pangea/activity_suggestions/activity_suggestion_card.dart +++ b/lib/pangea/activity_suggestions/activity_suggestion_card.dart @@ -4,7 +4,6 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart'; import 'package:fluffychat/pangea/activity_planner/activity_planner_builder.dart'; -import 'package:fluffychat/pangea/common/widgets/pressable_button.dart'; import 'package:fluffychat/widgets/mxc_image.dart'; class ActivitySuggestionCard extends StatelessWidget { @@ -13,12 +12,19 @@ class ActivitySuggestionCard extends StatelessWidget { final double width; final double height; + final double? fontSize; + final double? fontSizeSmall; + final double? iconSize; + const ActivitySuggestionCard({ super.key, required this.controller, required this.onPressed, required this.width, required this.height, + this.fontSize, + this.fontSizeSmall, + this.iconSize, }); ActivityPlanModel get activity => controller.updatedActivity; @@ -26,40 +32,26 @@ class ActivitySuggestionCard extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); - return PressableButton( - onPressed: onPressed, - borderRadius: BorderRadius.circular(24.0), - color: theme.brightness == Brightness.dark - ? theme.colorScheme.primary - : theme.colorScheme.surfaceContainerHighest, - colorFactor: theme.brightness == Brightness.dark ? 0.6 : 0.2, - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(24.0), - ), - height: height, - width: width, - child: Stack( - alignment: Alignment.topCenter, - children: [ - Container( + return MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: onPressed, + child: SizedBox( + height: height, + width: width, + child: ClipRRect( + borderRadius: BorderRadius.circular(12.0), + child: Container( decoration: BoxDecoration( color: theme.colorScheme.surfaceContainer, - borderRadius: BorderRadius.circular(24.0), ), - ), - Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Container( - height: width, - width: width, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(24.0), - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(24.0), + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + SizedBox( + height: width, + width: width, child: activity.imageURL != null ? activity.imageURL!.startsWith("mxc") ? MxcImage( @@ -77,104 +69,70 @@ class ActivitySuggestionCard extends StatelessWidget { errorWidget: (context, url, error) => const SizedBox(), fit: BoxFit.cover, + width: width, + height: width, ) - : null, + : const SizedBox(), ), - ), - Expanded( - child: Padding( - padding: const EdgeInsets.all(12.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - spacing: 8.0, - children: [ - Row( - children: [ - Flexible( - child: Text( - activity.title, - style: const TextStyle( - fontWeight: FontWeight.bold, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), + Expanded( + child: Padding( + padding: const EdgeInsets.all(4.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + activity.title, + style: TextStyle( + fontSize: fontSize, ), - ], - ), - Row( - mainAxisSize: MainAxisSize.min, - spacing: 8.0, - children: [ - Container( - decoration: BoxDecoration( - color: theme.colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(24.0), - ), - padding: const EdgeInsets.symmetric( - vertical: 2.0, - horizontal: 8.0, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - spacing: 8.0, - children: [ - const Icon( - Icons.group_outlined, - size: 12.0, - ), - Text( - "${activity.req.numberOfParticipants}", - style: theme.textTheme.labelSmall, - ), - ], - ), - ), - if (activity.req.mode.isNotEmpty) - Flexible( - child: Container( - decoration: BoxDecoration( - color: theme.colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(24.0), - ), - padding: const EdgeInsets.symmetric( - vertical: 2.0, - horizontal: 8.0, - ), + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + Row( + mainAxisSize: MainAxisSize.min, + spacing: 8.0, + children: [ + if (activity.req.mode.isNotEmpty) + Padding( + padding: const EdgeInsets.all(4.0), child: Text( activity.req.mode, - style: theme.textTheme.labelSmall, + style: fontSizeSmall != null + ? TextStyle(fontSize: fontSizeSmall) + : theme.textTheme.labelSmall, overflow: TextOverflow.ellipsis, ), ), + Padding( + padding: const EdgeInsets.all(4.0), + child: Row( + mainAxisSize: MainAxisSize.min, + spacing: 4.0, + children: [ + Icon( + Icons.group_outlined, + size: iconSize ?? 12.0, + ), + Text( + "${activity.req.numberOfParticipants}", + style: fontSizeSmall != null + ? TextStyle(fontSize: fontSizeSmall) + : theme.textTheme.labelSmall, + ), + ], + ), ), - ], - ), - ], + ], + ), + ], + ), ), ), - ), - ], - ), - Positioned( - top: 4.0, - right: 4.0, - child: IconButton( - icon: Icon( - controller.isBookmarked ? Icons.save : Icons.save_outlined, - color: Theme.of(context).colorScheme.onPrimaryContainer, - ), - onPressed: controller.toggleBookmarkedActivity, - style: IconButton.styleFrom( - backgroundColor: Theme.of(context) - .colorScheme - .primaryContainer - .withAlpha(180), - ), + ], ), ), - ], + ), ), ), ); diff --git a/lib/pangea/activity_suggestions/activity_suggestion_dialog.dart b/lib/pangea/activity_suggestions/activity_suggestion_dialog.dart index 49f3e29cd..567ef6cde 100644 --- a/lib/pangea/activity_suggestions/activity_suggestion_dialog.dart +++ b/lib/pangea/activity_suggestions/activity_suggestion_dialog.dart @@ -74,8 +74,9 @@ class ActivitySuggestionDialogState extends State { final ids = await widget.controller.launchToSpace(); ids.length == 1 - ? context.go("/rooms/${ids.first}") - : context.go("/rooms?spaceId=${widget.controller.room.id}"); + ? context + .go("/rooms/spaces/${widget.controller.room.id}/${ids.first}") + : context.go("/rooms/spaces/${widget.controller.room.id}/details"); Navigator.of(context).pop(); } catch (e, s) { _launchError = L10n.of(context).errorLaunchActivityMessage; diff --git a/lib/pangea/activity_suggestions/activity_suggestion_dialog_content.dart b/lib/pangea/activity_suggestions/activity_suggestion_dialog_content.dart index adbe71b2a..6d09bff01 100644 --- a/lib/pangea/activity_suggestions/activity_suggestion_dialog_content.dart +++ b/lib/pangea/activity_suggestions/activity_suggestion_dialog_content.dart @@ -241,23 +241,24 @@ class _ActivitySuggestionBaseContent extends StatelessWidget { Row( spacing: 12.0, children: [ - Expanded( - child: ElevatedButton( - style: controller.buttonStyle, - onPressed: activityController.startEditing, - child: Row( - children: [ - const Icon(Icons.edit), - Expanded( - child: Text( - L10n.of(context).edit, - textAlign: TextAlign.center, + if (activityController.widget.enabledEdits) + Expanded( + child: ElevatedButton( + style: controller.buttonStyle, + onPressed: activityController.startEditing, + child: Row( + children: [ + const Icon(Icons.edit), + Expanded( + child: Text( + L10n.of(context).edit, + textAlign: TextAlign.center, + ), ), - ), - ], + ], + ), ), ), - ), if (controller.widget.replaceActivity != null) Expanded( child: ElevatedButton( @@ -287,9 +288,11 @@ class _ActivitySuggestionBaseContent extends StatelessWidget { style: controller.buttonStyle, // onPressed: _launchActivity, onPressed: () { - activityController.setLaunchState( - ActivityLaunchState.launching, - ); + !activityController.widget.enableMultiLaunch + ? controller.launchActivity() + : activityController.setLaunchState( + ActivityLaunchState.launching, + ); }, child: Row( spacing: 12.0, @@ -393,7 +396,7 @@ class _ActivitySuggestionEditContent extends StatelessWidget { child: TextFormField( controller: activityController.participantsController, decoration: InputDecoration( - labelText: L10n.of(context).classRoster, + labelText: L10n.of(context).participants, ), maxLines: 1, keyboardType: TextInputType.number, diff --git a/lib/pangea/analytics_page/analytics_page_view.dart b/lib/pangea/analytics_page/analytics_page_view.dart index 8369837c4..818d13233 100644 --- a/lib/pangea/analytics_page/analytics_page_view.dart +++ b/lib/pangea/analytics_page/analytics_page_view.dart @@ -1,9 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; - -import 'package:fluffychat/config/app_config.dart'; -import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/pangea/analytics_details_popup/analytics_details_popup.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; import 'package:fluffychat/pangea/analytics_page/activity_archive.dart'; @@ -12,7 +8,6 @@ import 'package:fluffychat/pangea/analytics_summary/learning_progress_indicators import 'package:fluffychat/pangea/analytics_summary/level_dialog_content.dart'; import 'package:fluffychat/pangea/analytics_summary/progress_indicators_enum.dart'; import 'package:fluffychat/widgets/matrix.dart'; -import 'package:fluffychat/widgets/navigation_rail.dart'; class AnalyticsPageView extends StatelessWidget { final AnalyticsPageState controller; @@ -23,70 +18,51 @@ class AnalyticsPageView extends StatelessWidget { @override Widget build(BuildContext context) { - final isColumnMode = FluffyThemes.isColumnMode(context); + return Scaffold( + body: SafeArea( + child: Padding( + padding: const EdgeInsetsGeometry.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + LearningProgressIndicators( + selected: controller.selectedIndicator, + canSelect: + controller.selectedIndicator != ProgressIndicatorEnum.level, + ), + Expanded( + child: StreamBuilder( + stream: MatrixState + .pangeaController.getAnalytics.analyticsStream.stream, + builder: (context, _) { + if (controller.selectedIndicator == + ProgressIndicatorEnum.level) { + return const LevelDialogContent(); + } else if (controller.selectedIndicator == + ProgressIndicatorEnum.morphsUsed) { + return AnalyticsPopupWrapper( + constructZoom: controller.widget.constructZoom, + view: ConstructTypeEnum.morph, + ); + } else if (controller.selectedIndicator == + ProgressIndicatorEnum.wordsUsed) { + return AnalyticsPopupWrapper( + constructZoom: controller.widget.constructZoom, + view: ConstructTypeEnum.vocab, + ); + } else if (controller.selectedIndicator == + ProgressIndicatorEnum.activities) { + return const ActivityArchive(); + } - return Row( - children: [ - if (!isColumnMode && AppConfig.displayNavigationRail) ...[ - SpacesNavigationRail( - activeSpaceId: null, - onGoToChats: () => context.go('/rooms'), - onGoToSpaceId: (spaceId) => context.go('/rooms?spaceId=$spaceId'), - ), - Container( - color: Theme.of(context).dividerColor, - width: 1, - ), - ], - Expanded( - child: Scaffold( - body: SafeArea( - child: Padding( - padding: const EdgeInsetsGeometry.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - LearningProgressIndicators( - selected: controller.selectedIndicator, - canSelect: controller.selectedIndicator != - ProgressIndicatorEnum.level, - ), - Expanded( - child: StreamBuilder( - stream: MatrixState.pangeaController.getAnalytics - .analyticsStream.stream, - builder: (context, _) { - if (controller.selectedIndicator == - ProgressIndicatorEnum.level) { - return const LevelDialogContent(); - } else if (controller.selectedIndicator == - ProgressIndicatorEnum.morphsUsed) { - return AnalyticsPopupWrapper( - constructZoom: controller.widget.constructZoom, - view: ConstructTypeEnum.morph, - ); - } else if (controller.selectedIndicator == - ProgressIndicatorEnum.wordsUsed) { - return AnalyticsPopupWrapper( - constructZoom: controller.widget.constructZoom, - view: ConstructTypeEnum.vocab, - ); - } else if (controller.selectedIndicator == - ProgressIndicatorEnum.activities) { - return const ActivityArchive(); - } - - return const SizedBox(); - }, - ), - ), - ], + return const SizedBox(); + }, ), ), - ), + ], ), ), - ], + ), ); } } diff --git a/lib/pangea/chat/constants/default_power_level.dart b/lib/pangea/chat/constants/default_power_level.dart index 9b9b4f471..c79a40276 100644 --- a/lib/pangea/chat/constants/default_power_level.dart +++ b/lib/pangea/chat/constants/default_power_level.dart @@ -57,7 +57,11 @@ class RoomDefaults { }, ); - static StateEvent defaultSpacePowerLevels(String userID) => StateEvent( + static StateEvent defaultSpacePowerLevels( + String userID, { + int spaceChild = 50, + }) => + StateEvent( type: EventTypes.RoomPowerLevels, stateKey: '', content: { @@ -68,7 +72,7 @@ class RoomDefaults { "events": { "m.room.power_levels": 100, "m.room.join_rules": 100, - "m.space.child": 50, + "m.space.child": spaceChild, }, "events_default": 0, "state_default": 50, diff --git a/lib/pangea/chat_list/utils/chat_list_handle_space_tap.dart b/lib/pangea/chat_list/utils/chat_list_handle_space_tap.dart index 72839b716..98fc88439 100644 --- a/lib/pangea/chat_list/utils/chat_list_handle_space_tap.dart +++ b/lib/pangea/chat_list/utils/chat_list_handle_space_tap.dart @@ -28,7 +28,9 @@ Future showInviteDialog(Room room, BuildContext context) async { if (acceptInvite == OkCancelResult.ok) { await room.join(); context.go( - room.isSpace ? "/rooms?spaceId=${room.id}" : "/rooms/${room.id}", + room.isSpace + ? "/rooms/spaces/${room.id}/details" + : "/rooms/${room.id}", ); return room.id; } else if (acceptInvite == OkCancelResult.cancel) { @@ -38,7 +40,7 @@ Future showInviteDialog(Room room, BuildContext context) async { ); if (!resp.isError && resp.result is String) { - context.go("/rooms?spaceId=${resp.result}"); + context.go("/rooms/spaces/${resp.result}/details"); } } @@ -48,7 +50,7 @@ void chatListHandleSpaceTap( Room space, ) { void setActiveSpaceAndCloseChat() { - context.go("/rooms?spaceId=${space.id}"); + context.go("/rooms/spaces/${space.id}/details"); } void autoJoin(Room space) { diff --git a/lib/pangea/chat_settings/constants/pangea_room_types.dart b/lib/pangea/chat_settings/constants/pangea_room_types.dart index 804d2be86..62b8b93ca 100644 --- a/lib/pangea/chat_settings/constants/pangea_room_types.dart +++ b/lib/pangea/chat_settings/constants/pangea_room_types.dart @@ -1,3 +1,4 @@ class PangeaRoomTypes { static const analytics = 'p.analytics'; + static const activitySession = 'p.activity.session'; } diff --git a/lib/pangea/chat_settings/pages/chat_details_button_row.dart b/lib/pangea/chat_settings/pages/chat_details_button_row.dart new file mode 100644 index 000000000..d64b44079 --- /dev/null +++ b/lib/pangea/chat_settings/pages/chat_details_button_row.dart @@ -0,0 +1,271 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import 'package:go_router/go_router.dart'; +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/config/themes.dart'; +import 'package:fluffychat/l10n/l10n.dart'; +import 'package:fluffychat/pages/chat_details/chat_details.dart'; +import 'package:fluffychat/pangea/bot/widgets/bot_face_svg.dart'; +import 'package:fluffychat/pangea/chat_settings/models/bot_options_model.dart'; +import 'package:fluffychat/pangea/chat_settings/pages/room_details_buttons.dart'; +import 'package:fluffychat/pangea/chat_settings/utils/delete_room.dart'; +import 'package:fluffychat/pangea/chat_settings/widgets/conversation_bot/conversation_bot_settings.dart'; +import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; +import 'package:fluffychat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart'; +import 'package:fluffychat/widgets/future_loading_dialog.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +class ChatDetailsButtonRow extends StatefulWidget { + final ChatDetailsController controller; + final Room room; + + const ChatDetailsButtonRow({ + super.key, + required this.controller, + required this.room, + }); + + @override + State createState() => ChatDetailsButtonRowState(); +} + +class ChatDetailsButtonRowState extends State { + StreamSubscription? notificationChangeSub; + + @override + void initState() { + super.initState(); + notificationChangeSub ??= Matrix.of(context) + .client + .onSync + .stream + .where( + (syncUpdate) => + syncUpdate.accountData?.any( + (accountData) => accountData.type == 'm.push_rules', + ) ?? + false, + ) + .listen( + (u) => setState(() {}), + ); + } + + @override + void dispose() { + notificationChangeSub?.cancel(); + super.dispose(); + } + + final double _buttonHeight = 84.0; + final double _miniButtonWidth = 50.0; + + Room get room => widget.room; + + List _buttons(BuildContext context) { + final L10n l10n = L10n.of(context); + return [ + ButtonDetails( + title: l10n.permissions, + icon: const Icon(Icons.edit_attributes_outlined, size: 30.0), + onPressed: () => context.go('/rooms/${room.id}/details/permissions'), + enabled: room.isRoomAdmin && !room.isDirectChat, + showInMainView: false, + ), + ButtonDetails( + title: room.pushRuleState == PushRuleState.notify + ? l10n.notificationsOn + : l10n.notificationsOff, + icon: Icon( + room.pushRuleState == PushRuleState.notify + ? Icons.notifications_on_outlined + : Icons.notifications_off_outlined, + size: 30.0, + ), + onPressed: () => showFutureLoadingDialog( + context: context, + future: () => room.setPushRuleState( + room.pushRuleState == PushRuleState.notify + ? PushRuleState.mentionsOnly + : PushRuleState.notify, + ), + ), + ), + ButtonDetails( + title: l10n.invite, + icon: const Icon(Icons.person_add_outlined, size: 30.0), + onPressed: () { + String filter = 'knocking'; + if (room.getParticipants([Membership.knock]).isEmpty) { + filter = room.pangeaSpaceParents.isNotEmpty ? 'space' : 'contacts'; + } + context.go('/rooms/${room.id}/details/invite?filter=$filter'); + }, + enabled: room.canInvite && !room.isDirectChat, + ), + ButtonDetails( + title: l10n.download, + icon: const Icon(Icons.download_outlined, size: 30.0), + onPressed: widget.controller.downloadChatAction, + visible: kIsWeb, + enabled: room.ownPowerLevel >= 50, + showInMainView: false, + ), + ButtonDetails( + title: l10n.botSettings, + icon: const BotFace( + width: 30.0, + expression: BotExpression.idle, + ), + onPressed: () => showDialog( + context: context, + builder: (BuildContext context) => ConversationBotSettingsDialog( + room: room, + onSubmit: widget.controller.setBotOptions, + ), + ), + visible: !room.isDirectChat || room.botOptions != null, + enabled: room.canInvite, + ), + ButtonDetails( + title: l10n.chatCapacity, + icon: const Icon(Icons.reduce_capacity, size: 30.0), + onPressed: widget.controller.setRoomCapacity, + enabled: !room.isDirectChat && room.canSendDefaultStates, + showInMainView: false, + ), + ButtonDetails( + title: l10n.leave, + icon: const Icon(Icons.logout_outlined, size: 30.0), + onPressed: () async { + final confirmed = await showOkCancelAlertDialog( + useRootNavigator: false, + context: context, + title: L10n.of(context).areYouSure, + okLabel: L10n.of(context).leave, + cancelLabel: L10n.of(context).no, + message: L10n.of(context).leaveRoomDescription, + isDestructive: true, + ); + if (confirmed != OkCancelResult.ok) return; + final resp = await showFutureLoadingDialog( + context: context, + future: room.leave, + ); + if (!resp.isError) { + context.go("/rooms"); + } + }, + enabled: room.membership == Membership.join, + showInMainView: false, + ), + ButtonDetails( + title: l10n.delete, + icon: const Icon(Icons.delete_outline, size: 30.0), + onPressed: () async { + final confirmed = await showOkCancelAlertDialog( + context: context, + title: L10n.of(context).areYouSure, + okLabel: L10n.of(context).delete, + cancelLabel: L10n.of(context).cancel, + isDestructive: true, + message: L10n.of(context).deleteChatDesc, + ); + if (confirmed != OkCancelResult.ok) return; + + final resp = await showFutureLoadingDialog( + context: context, + future: room.delete, + ); + if (resp.isError) return; + context.go("/rooms"); + }, + enabled: room.isRoomAdmin && !room.isDirectChat, + showInMainView: false, + ), + ]; + } + + @override + Widget build(BuildContext context) { + final buttons = _buttons(context) + .where( + (button) => button.visible, + ) + .toList(); + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 16.0), + child: LayoutBuilder( + builder: (context, constraints) { + final availableWidth = constraints.maxWidth; + final fullButtonCapacity = (availableWidth / 120.0).floor() - 1; + + final mini = fullButtonCapacity < 4; + + final List mainViewButtons = + buttons.where((button) => button.showInMainView).toList(); + final List otherButtons = + buttons.where((button) => !button.showInMainView).toList(); + + return Row( + spacing: FluffyThemes.isColumnMode(context) ? 12.0 : 0.0, + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate(mainViewButtons.length + 1, (index) { + if (index == mainViewButtons.length) { + if (otherButtons.isEmpty) { + return const SizedBox(); + } + + return Expanded( + child: PopupMenuButton( + useRootNavigator: true, + itemBuilder: (context) { + return otherButtons + .map( + (button) => PopupMenuItem( + value: button, + onTap: button.enabled ? button.onPressed : null, + enabled: button.enabled, + child: Row( + children: [ + button.icon, + const SizedBox(width: 8), + Text(button.title), + ], + ), + ), + ) + .toList(); + }, + child: RoomDetailsButton( + mini: mini, + buttonDetails: ButtonDetails( + title: L10n.of(context).more, + icon: const Icon(Icons.more_horiz_outlined), + ), + height: mini ? _miniButtonWidth : _buttonHeight, + ), + ), + ); + } + + final button = mainViewButtons[index]; + return Expanded( + child: RoomDetailsButton( + mini: mini, + buttonDetails: button, + height: mini ? _miniButtonWidth : _buttonHeight, + ), + ); + }), + ); + }, + ), + ); + } +} diff --git a/lib/pangea/chat_settings/pages/chat_details_content.dart b/lib/pangea/chat_settings/pages/chat_details_content.dart new file mode 100644 index 000000000..d85eabe71 --- /dev/null +++ b/lib/pangea/chat_settings/pages/chat_details_content.dart @@ -0,0 +1,206 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_linkify/flutter_linkify.dart'; +import 'package:go_router/go_router.dart'; +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/l10n/l10n.dart'; +import 'package:fluffychat/pages/chat_details/chat_details.dart'; +import 'package:fluffychat/pangea/chat_settings/pages/chat_details_button_row.dart'; +import 'package:fluffychat/pangea/chat_settings/pages/room_participants_widget.dart'; +import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; +import 'package:fluffychat/utils/fluffy_share.dart'; +import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart'; +import 'package:fluffychat/utils/url_launcher.dart'; +import 'package:fluffychat/widgets/avatar.dart'; + +class ChatDetailsContent extends StatelessWidget { + final ChatDetailsController controller; + final Room room; + + const ChatDetailsContent(this.controller, this.room, {super.key}); + + @override + Widget build(BuildContext context) { + return ListView.builder( + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, + itemCount: 2, + itemBuilder: (BuildContext context, int i) { + if (i == 0) { + final theme = Theme.of(context); + final displayname = room.getLocalizedDisplayname( + MatrixLocals(L10n.of(context)), + ); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Padding( + padding: const EdgeInsets.all(32.0), + child: Stack( + children: [ + Hero( + tag: controller.widget.embeddedCloseButton != null + ? 'embedded_content_banner' + : 'content_banner', + child: Avatar( + mxContent: room.avatar, + name: displayname, + userId: room.directChatMatrixID, + size: Avatar.defaultSize * 2.5, + borderRadius: room.isSpace + ? BorderRadius.circular(24.0) + : null, + ), + ), + if (!room.isDirectChat && + room.canChangeStateEvent( + EventTypes.RoomAvatar, + )) + Positioned( + bottom: 0, + right: 0, + child: FloatingActionButton.small( + onPressed: controller.setAvatarAction, + heroTag: null, + child: const Icon( + Icons.camera_alt_outlined, + ), + ), + ), + ], + ), + ), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextButton.icon( + onPressed: room.isDirectChat + ? null + : () => room.canChangeStateEvent( + EventTypes.RoomName, + ) + ? controller.setDisplaynameAction() + : FluffyShare.share( + displayname, + context, + copyOnly: true, + ), + icon: Icon( + room.isDirectChat + ? Icons.chat_bubble_outline + : room.canChangeStateEvent( + EventTypes.RoomName, + ) + ? Icons.edit_outlined + : Icons.copy_outlined, + size: 16, + ), + style: TextButton.styleFrom( + foregroundColor: theme.colorScheme.onSurface, + disabledForegroundColor: + theme.colorScheme.onSurface, + ), + label: Text( + room.isDirectChat + ? L10n.of(context).directChat + : displayname, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 18), + ), + ), + TextButton.icon( + onPressed: room.isDirectChat + ? null + : () => context.push( + '/rooms/${controller.roomId}/details/invite?filter=participants', + ), + icon: const Icon( + Icons.group_outlined, + size: 14, + ), + style: TextButton.styleFrom( + foregroundColor: theme.colorScheme.secondary, + disabledForegroundColor: + theme.colorScheme.onSurface, + ), + label: Text( + L10n.of(context).countParticipants( + room.getParticipants().length, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ], + ), + Stack( + children: [ + if (room.isRoomAdmin) + Positioned( + right: 4, + top: 4, + child: IconButton( + onPressed: controller.setTopicAction, + icon: const Icon(Icons.edit_outlined), + ), + ), + Padding( + padding: const EdgeInsets.only( + left: 32.0, + right: 32.0, + top: 16.0, + bottom: 16.0, + ), + child: SelectableLinkify( + text: room.topic.isEmpty + ? room.isSpace + ? L10n.of(context).noSpaceDescriptionYet + : L10n.of(context).noChatDescriptionYet + : room.topic, + options: const LinkifyOptions(humanize: false), + linkStyle: const TextStyle( + color: Colors.blueAccent, + decorationColor: Colors.blueAccent, + ), + style: TextStyle( + fontSize: 14, + fontStyle: room.topic.isEmpty + ? FontStyle.italic + : FontStyle.normal, + color: theme.textTheme.bodyMedium!.color, + decorationColor: theme.textTheme.bodyMedium!.color, + ), + onOpen: (url) => + UrlLauncher(context, url.url).launchUrl(), + ), + ), + ], + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: ChatDetailsButtonRow( + controller: controller, + room: room, + ), + ), + ], + ); + } + + return Padding( + padding: const EdgeInsets.all(16.0), + child: RoomParticipantsSection(room: room), + ); + }, + ); + } +} diff --git a/lib/pangea/chat_settings/pages/pangea_chat_details.dart b/lib/pangea/chat_settings/pages/pangea_chat_details.dart deleted file mode 100644 index 8ace4beec..000000000 --- a/lib/pangea/chat_settings/pages/pangea_chat_details.dart +++ /dev/null @@ -1,882 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; - -import 'package:collection/collection.dart'; -import 'package:flutter_linkify/flutter_linkify.dart'; -import 'package:go_router/go_router.dart'; -import 'package:matrix/matrix.dart'; - -import 'package:fluffychat/config/app_config.dart'; -import 'package:fluffychat/config/themes.dart'; -import 'package:fluffychat/l10n/l10n.dart'; -import 'package:fluffychat/pages/chat_details/chat_details.dart'; -import 'package:fluffychat/pangea/analytics_misc/level_display_name.dart'; -import 'package:fluffychat/pangea/bot/utils/bot_name.dart'; -import 'package:fluffychat/pangea/bot/widgets/bot_face_svg.dart'; -import 'package:fluffychat/pangea/chat_settings/models/bot_options_model.dart'; -import 'package:fluffychat/pangea/chat_settings/utils/delete_room.dart'; -import 'package:fluffychat/pangea/chat_settings/widgets/conversation_bot/conversation_bot_settings.dart'; -import 'package:fluffychat/pangea/chat_settings/widgets/delete_space_dialog.dart'; -import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart'; -import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; -import 'package:fluffychat/pangea/spaces/utils/load_participants_util.dart'; -import 'package:fluffychat/utils/fluffy_share.dart'; -import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart'; -import 'package:fluffychat/utils/url_launcher.dart'; -import 'package:fluffychat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart'; -import 'package:fluffychat/widgets/avatar.dart'; -import 'package:fluffychat/widgets/future_loading_dialog.dart'; -import 'package:fluffychat/widgets/hover_builder.dart'; -import 'package:fluffychat/widgets/layouts/max_width_body.dart'; -import 'package:fluffychat/widgets/matrix.dart'; -import 'package:fluffychat/widgets/member_actions_popup_menu_button.dart'; - -class PangeaChatDetailsView extends StatelessWidget { - final ChatDetailsController controller; - - const PangeaChatDetailsView(this.controller, {super.key}); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - final room = Matrix.of(context).client.getRoomById(controller.roomId!); - if (room == null || room.membership == Membership.leave) { - return Scaffold( - appBar: AppBar( - title: Text(L10n.of(context).oopsSomethingWentWrong), - ), - body: Center( - child: Text(L10n.of(context).youAreNoLongerParticipatingInThisChat), - ), - ); - } - - return StreamBuilder( - stream: room.client.onRoomState.stream - .where((update) => update.roomId == room.id), - builder: (context, snapshot) { - var members = room.getParticipants().toList() - ..sort((b, a) => a.powerLevel.compareTo(b.powerLevel)); - members = members.take(10).toList(); - final actualMembersCount = (room.summary.mInvitedMemberCount ?? 0) + - (room.summary.mJoinedMemberCount ?? 0); - final displayname = room.getLocalizedDisplayname( - MatrixLocals(L10n.of(context)), - ); - return Scaffold( - appBar: AppBar( - leading: controller.widget.embeddedCloseButton ?? - (room.isSpace - ? FluffyThemes.isColumnMode(context) - ? const SizedBox() - : BackButton( - onPressed: () => - context.go("/rooms?spaceId=${room.id}"), - ) - : const Center(child: BackButton())), - ), - body: MaxWidthBody( - maxWidth: 900, - showBorder: false, - child: ListView.builder( - physics: const NeverScrollableScrollPhysics(), - shrinkWrap: true, - itemCount: 2, - itemBuilder: (BuildContext context, int i) => i == 0 - ? Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - children: [ - Padding( - padding: const EdgeInsets.all(32.0), - child: Stack( - children: [ - Hero( - tag: - controller.widget.embeddedCloseButton != - null - ? 'embedded_content_banner' - : 'content_banner', - child: Avatar( - mxContent: room.avatar, - name: displayname, - userId: room.directChatMatrixID, - size: Avatar.defaultSize * 2.5, - borderRadius: room.isSpace - ? BorderRadius.circular(24.0) - : null, - ), - ), - if (!room.isDirectChat && - room.canChangeStateEvent( - EventTypes.RoomAvatar, - )) - Positioned( - bottom: 0, - right: 0, - child: FloatingActionButton.small( - onPressed: controller.setAvatarAction, - heroTag: null, - child: const Icon( - Icons.camera_alt_outlined, - ), - ), - ), - ], - ), - ), - Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - TextButton.icon( - onPressed: room.isDirectChat - ? null - : () => room.canChangeStateEvent( - EventTypes.RoomName, - ) - ? controller - .setDisplaynameAction() - : FluffyShare.share( - displayname, - context, - copyOnly: true, - ), - icon: Icon( - room.isDirectChat - ? Icons.chat_bubble_outline - : room.canChangeStateEvent( - EventTypes.RoomName, - ) - ? Icons.edit_outlined - : Icons.copy_outlined, - size: 16, - ), - style: TextButton.styleFrom( - foregroundColor: - theme.colorScheme.onSurface, - disabledForegroundColor: - theme.colorScheme.onSurface, - ), - label: Text( - room.isDirectChat - ? L10n.of(context).directChat - : displayname, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 18), - ), - ), - TextButton.icon( - onPressed: room.isDirectChat - ? null - : () => context.push( - '/rooms/${controller.roomId}/details/invite?filter=participants', - ), - icon: const Icon( - Icons.group_outlined, - size: 14, - ), - style: TextButton.styleFrom( - foregroundColor: - theme.colorScheme.secondary, - disabledForegroundColor: - theme.colorScheme.onSurface, - ), - label: Text( - L10n.of(context).countParticipants( - actualMembersCount, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ), - ], - ), - Stack( - children: [ - if (room.isRoomAdmin) - Positioned( - right: 4, - top: 4, - child: IconButton( - onPressed: controller.setTopicAction, - icon: const Icon(Icons.edit_outlined), - ), - ), - Padding( - padding: const EdgeInsets.only( - left: 32.0, - right: 32.0, - top: 16.0, - bottom: 16.0, - ), - child: SelectableLinkify( - text: room.topic.isEmpty - ? room.isSpace - ? L10n.of(context).noSpaceDescriptionYet - : L10n.of(context).noChatDescriptionYet - : room.topic, - options: const LinkifyOptions(humanize: false), - linkStyle: const TextStyle( - color: Colors.blueAccent, - decorationColor: Colors.blueAccent, - ), - style: TextStyle( - fontSize: 14, - fontStyle: room.topic.isEmpty - ? FontStyle.italic - : FontStyle.normal, - color: theme.textTheme.bodyMedium!.color, - decorationColor: - theme.textTheme.bodyMedium!.color, - ), - onOpen: (url) => - UrlLauncher(context, url.url).launchUrl(), - ), - ), - ], - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: RoomDetailsButtonRow( - controller: controller, - room: room, - ), - ), - ], - ) - : Padding( - padding: const EdgeInsets.all(16.0), - child: RoomParticipantsSection(room: room), - ), - ), - ), - ); - }, - ); - } -} - -class RoomDetailsButtonRow extends StatefulWidget { - final ChatDetailsController controller; - final Room room; - - const RoomDetailsButtonRow({ - super.key, - required this.controller, - required this.room, - }); - - @override - State createState() => RoomDetailsButtonRowState(); -} - -class RoomDetailsButtonRowState extends State { - StreamSubscription? notificationChangeSub; - - @override - void initState() { - super.initState(); - notificationChangeSub ??= Matrix.of(context) - .client - .onSync - .stream - .where( - (syncUpdate) => - syncUpdate.accountData?.any( - (accountData) => accountData.type == 'm.push_rules', - ) ?? - false, - ) - .listen( - (u) => setState(() {}), - ); - } - - @override - void dispose() { - notificationChangeSub?.cancel(); - super.dispose(); - } - - final double _buttonHeight = 84.0; - final double _miniButtonWidth = 50.0; - - Room get room => widget.room; - - List _buttons(BuildContext context) { - final L10n l10n = L10n.of(context); - return [ - ButtonDetails( - title: l10n.activities, - icon: const Icon(Icons.event_note_outlined, size: 30.0), - onPressed: () => context.go("/rooms/${room.id}/details/planner"), - visible: room.isSpace, - enabled: room.canChangeStateEvent(PangeaEventTypes.activityPlan) && - room.isSpace, - ), - ButtonDetails( - title: l10n.permissions, - icon: const Icon(Icons.edit_attributes_outlined, size: 30.0), - onPressed: () => context.go('/rooms/${room.id}/details/permissions'), - visible: (room.isRoomAdmin && !room.isDirectChat) || room.isSpace, - enabled: room.isRoomAdmin && !room.isDirectChat, - showInMainView: false, - ), - ButtonDetails( - title: l10n.access, - icon: const Icon(Icons.shield_outlined, size: 30.0), - onPressed: () => context.go('/rooms/${room.id}/details/access'), - visible: room.isSpace && room.spaceParents.isEmpty, - enabled: room.isSpace && room.isRoomAdmin, - ), - ButtonDetails( - title: room.pushRuleState == PushRuleState.notify - ? l10n.notificationsOn - : l10n.notificationsOff, - icon: Icon( - room.pushRuleState == PushRuleState.notify - ? Icons.notifications_on_outlined - : Icons.notifications_off_outlined, - size: 30.0, - ), - onPressed: () => showFutureLoadingDialog( - context: context, - future: () => room.setPushRuleState( - room.pushRuleState == PushRuleState.notify - ? PushRuleState.mentionsOnly - : PushRuleState.notify, - ), - ), - visible: !room.isSpace, - ), - ButtonDetails( - title: l10n.invite, - icon: const Icon(Icons.person_add_outlined, size: 30.0), - onPressed: () { - String filter = 'knocking'; - if (room.getParticipants([Membership.knock]).isEmpty) { - filter = room.pangeaSpaceParents.isNotEmpty ? 'space' : 'contacts'; - } - context.go('/rooms/${room.id}/details/invite?filter=$filter'); - }, - visible: (room.canInvite && !room.isDirectChat) || room.isSpace, - enabled: room.canInvite && !room.isDirectChat, - ), - ButtonDetails( - title: l10n.addSubspace, - icon: const Icon(Icons.add_outlined, size: 30.0), - onPressed: widget.controller.addSubspace, - visible: room.isSpace && - room.canChangeStateEvent( - EventTypes.SpaceChild, - ), - showInMainView: false, - ), - ButtonDetails( - title: l10n.spaceAnalytics, - icon: const Icon(Icons.bar_chart, size: 30.0), - onPressed: () => context.go('/rooms/${room.id}/details/analytics'), - visible: room.isSpace, - enabled: room.isSpace && room.isRoomAdmin, - showInMainView: true, - ), - ButtonDetails( - title: l10n.download, - icon: const Icon(Icons.download_outlined, size: 30.0), - onPressed: widget.controller.downloadChatAction, - visible: room.ownPowerLevel >= 50 && !room.isSpace && kIsWeb, - showInMainView: false, - ), - ButtonDetails( - title: l10n.botSettings, - icon: const BotFace( - width: 30.0, - expression: BotExpression.idle, - ), - onPressed: () => showDialog( - context: context, - builder: (BuildContext context) => ConversationBotSettingsDialog( - room: room, - onSubmit: widget.controller.setBotOptions, - ), - ), - visible: !room.isSpace && - (!room.isDirectChat || room.botOptions != null) && - room.canInvite, - ), - ButtonDetails( - title: l10n.chatCapacity, - icon: const Icon(Icons.reduce_capacity, size: 30.0), - onPressed: widget.controller.setRoomCapacity, - visible: - !room.isSpace && !room.isDirectChat && room.canSendDefaultStates, - showInMainView: false, - ), - ButtonDetails( - title: l10n.leave, - icon: const Icon(Icons.logout_outlined, size: 30.0), - onPressed: () async { - final confirmed = await showOkCancelAlertDialog( - useRootNavigator: false, - context: context, - title: L10n.of(context).areYouSure, - okLabel: L10n.of(context).leave, - cancelLabel: L10n.of(context).no, - message: room.isSpace - ? L10n.of(context).leaveSpaceDescription - : L10n.of(context).leaveRoomDescription, - isDestructive: true, - ); - if (confirmed != OkCancelResult.ok) return; - final resp = await showFutureLoadingDialog( - context: context, - future: room.isSpace ? room.leaveSpace : room.leave, - ); - if (!resp.isError) { - context.go("/rooms?spaceId=clear"); - } - }, - visible: room.membership == Membership.join, - showInMainView: false, - ), - ButtonDetails( - title: l10n.delete, - icon: const Icon(Icons.delete_outline, size: 30.0), - onPressed: () async { - if (room.isSpace) { - final resp = await showDialog( - context: context, - builder: (_) => DeleteSpaceDialog(space: room), - ); - - if (resp == true) { - context.go("/rooms?spaceId=clear"); - } - } else { - final confirmed = await showOkCancelAlertDialog( - context: context, - title: L10n.of(context).areYouSure, - okLabel: L10n.of(context).delete, - cancelLabel: L10n.of(context).cancel, - isDestructive: true, - message: room.isSpace - ? L10n.of(context).deleteSpaceDesc - : L10n.of(context).deleteChatDesc, - ); - if (confirmed != OkCancelResult.ok) return; - - final resp = await showFutureLoadingDialog( - context: context, - future: room.delete, - ); - if (resp.isError) return; - context.go("/rooms?spaceId=clear"); - } - }, - visible: room.isRoomAdmin && !room.isDirectChat, - showInMainView: false, - ), - ]; - } - - @override - Widget build(BuildContext context) { - final buttons = _buttons(context) - .where( - (button) => button.visible, - ) - .toList(); - - return Padding( - padding: const EdgeInsets.symmetric(vertical: 16.0), - child: LayoutBuilder( - builder: (context, constraints) { - final availableWidth = constraints.maxWidth; - final fullButtonCapacity = (availableWidth / 120.0).floor() - 1; - - final mini = fullButtonCapacity < 4; - - final List mainViewButtons = - buttons.where((button) => button.showInMainView).toList(); - final List otherButtons = - buttons.where((button) => !button.showInMainView).toList(); - - return Row( - mainAxisAlignment: MainAxisAlignment.center, - children: List.generate(mainViewButtons.length + 1, (index) { - if (index == mainViewButtons.length) { - if (otherButtons.isEmpty) { - return const SizedBox(); - } - - return Expanded( - child: PopupMenuButton( - useRootNavigator: true, - onSelected: (button) => button.onPressed?.call(), - itemBuilder: (context) { - return otherButtons - .map( - (button) => PopupMenuItem( - value: button, - child: Row( - children: [ - button.icon, - const SizedBox(width: 8), - Text(button.title), - ], - ), - ), - ) - .toList(); - }, - child: RoomDetailsButton( - mini: mini, - buttonDetails: ButtonDetails( - title: L10n.of(context).more, - icon: const Icon(Icons.more_horiz_outlined), - visible: true, - ), - height: mini ? _miniButtonWidth : _buttonHeight, - ), - ), - ); - } - - final button = mainViewButtons[index]; - return Expanded( - child: RoomDetailsButton( - mini: mini, - buttonDetails: button, - height: mini ? _miniButtonWidth : _buttonHeight, - ), - ); - }), - ); - }, - ), - ); - } -} - -class RoomDetailsButton extends StatelessWidget { - final bool mini; - final double height; - - final ButtonDetails buttonDetails; - - const RoomDetailsButton({ - super.key, - required this.buttonDetails, - required this.mini, - required this.height, - }); - - @override - Widget build(BuildContext context) { - if (!buttonDetails.visible) { - return const SizedBox(); - } - - return TooltipVisibility( - visible: mini, - child: Tooltip( - message: buttonDetails.title, - child: AbsorbPointer( - absorbing: !buttonDetails.enabled, - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: HoverBuilder( - builder: (context, hovered) { - return GestureDetector( - onTap: buttonDetails.onPressed, - child: Opacity( - opacity: buttonDetails.enabled ? 1.0 : 0.5, - child: Container( - alignment: Alignment.center, - height: height, - decoration: BoxDecoration( - color: hovered - ? Theme.of(context) - .colorScheme - .primary - .withAlpha(50) - : Colors.transparent, - borderRadius: BorderRadius.circular(8), - ), - padding: EdgeInsets.all(mini ? 6 : 12.0), - child: mini - ? buttonDetails.icon - : Column( - spacing: 12.0, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - buttonDetails.icon, - Text( - buttonDetails.title, - textAlign: TextAlign.center, - style: const TextStyle(fontSize: 12.0), - ), - ], - ), - ), - ), - ); - }, - ), - ), - ), - ), - ); - } -} - -class ButtonDetails { - final String title; - final Widget icon; - final VoidCallback? onPressed; - final bool visible; - final bool enabled; - final bool showInMainView; - - const ButtonDetails({ - required this.title, - required this.icon, - required this.visible, - this.onPressed, - this.enabled = true, - this.showInMainView = true, - }); -} - -class RoomParticipantsSection extends StatelessWidget { - final Room room; - - const RoomParticipantsSection({ - required this.room, - super.key, - }); - - final double _width = 100.0; - final double _spacing = 15.0; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return LoadParticipantsUtil( - space: room, - builder: (participantsLoader) { - final filteredParticipants = - participantsLoader.filteredParticipants(""); - - final originalLeaders = filteredParticipants.take(3).toList(); - filteredParticipants.sort((a, b) { - // always sort bot to the end - final aIsBot = a.id == BotName.byEnvironment; - final bIsBot = b.id == BotName.byEnvironment; - if (aIsBot && !bIsBot) { - return 1; - } else if (bIsBot && !aIsBot) { - return -1; - } - - // put knocking users at the front - if (a.membership == Membership.knock && - b.membership != Membership.knock) { - return -1; - } else if (b.membership == Membership.knock && - a.membership != Membership.knock) { - return 1; - } - - // then invited users - if (a.membership == Membership.invite && - b.membership != Membership.invite) { - return -1; - } else if (b.membership == Membership.invite && - a.membership != Membership.invite) { - return 1; - } - - // then admins - if (a.powerLevel == 100 && b.powerLevel != 100) { - return -1; - } else if (b.powerLevel == 100 && a.powerLevel != 100) { - return 1; - } - - return 0; - }); - - return Wrap( - spacing: _spacing, - runSpacing: _spacing, - alignment: WrapAlignment.center, - runAlignment: WrapAlignment.center, - children: [ - ...filteredParticipants.mapIndexed((index, user) { - final permissionBatch = user.powerLevel >= 100 - ? L10n.of(context).admin - : user.powerLevel >= 50 - ? L10n.of(context).moderator - : ''; - - final membershipBatch = switch (user.membership) { - Membership.ban => null, - Membership.invite => L10n.of(context).invited, - Membership.join => null, - Membership.knock => L10n.of(context).knocking, - Membership.leave => null, - }; - - final publicProfile = participantsLoader.getAnalyticsProfile( - user.id, - ); - - final leaderIndex = originalLeaders.indexOf(user); - LinearGradient? gradient; - if (leaderIndex != -1) { - gradient = leaderIndex.leaderboardGradient; - if (user.id == BotName.byEnvironment || - publicProfile == null || - publicProfile.level == null) { - gradient = null; - } - } - - return SizedBox( - width: _width, - child: Opacity( - opacity: user.membership == Membership.join ? 1.0 : 0.5, - child: Column( - spacing: 4.0, - children: [ - Stack( - alignment: Alignment.center, - children: [ - if (gradient != null) - CircleAvatar( - radius: _width / 2, - child: Container( - decoration: BoxDecoration( - shape: BoxShape.circle, - gradient: gradient, - ), - ), - ) - else - SizedBox( - height: _width, - width: _width, - ), - Builder( - builder: (context) { - return MouseRegion( - cursor: SystemMouseCursors.click, - child: GestureDetector( - onTap: () => showMemberActionsPopupMenu( - context: context, - user: user, - ), - child: Center( - child: Avatar( - mxContent: user.avatarUrl, - name: user.calcDisplayname(), - size: _width - 6.0, - presenceUserId: user.id, - presenceOffset: const Offset(0, 0), - presenceSize: 18.0, - ), - ), - ), - ); - }, - ), - ], - ), - Text( - user.calcDisplayname(), - style: theme.textTheme.labelLarge?.copyWith( - color: theme.colorScheme.primary, - fontWeight: FontWeight.bold, - ), - overflow: TextOverflow.ellipsis, - ), - Container( - height: 20.0, - alignment: Alignment.center, - child: LevelDisplayName( - userId: user.id, - textStyle: theme.textTheme.labelSmall, - ), - ), - Container( - height: 24.0, - alignment: Alignment.center, - child: membershipBatch != null - ? Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 4, - ), - decoration: BoxDecoration( - color: theme.colorScheme.secondaryContainer, - borderRadius: BorderRadius.circular( - AppConfig.borderRadius, - ), - ), - child: Text( - membershipBatch, - style: theme.textTheme.labelSmall?.copyWith( - color: - theme.colorScheme.onSecondaryContainer, - ), - ), - ) - : permissionBatch.isNotEmpty - ? Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 4, - ), - decoration: BoxDecoration( - color: user.powerLevel >= 100 - ? theme.colorScheme.tertiary - : theme.colorScheme.tertiaryContainer, - borderRadius: BorderRadius.circular( - AppConfig.borderRadius, - ), - ), - child: Text( - permissionBatch, - style: - theme.textTheme.labelSmall?.copyWith( - color: user.powerLevel >= 100 - ? theme.colorScheme.onTertiary - : theme.colorScheme - .onTertiaryContainer, - ), - ), - ) - : null, - ), - ], - ), - ), - ); - }), - ], - ); - }, - ); - } -} diff --git a/lib/pangea/chat_settings/pages/pangea_invitation_selection.dart b/lib/pangea/chat_settings/pages/pangea_invitation_selection.dart index 0d705f35b..af8520748 100644 --- a/lib/pangea/chat_settings/pages/pangea_invitation_selection.dart +++ b/lib/pangea/chat_settings/pages/pangea_invitation_selection.dart @@ -127,7 +127,7 @@ class PangeaInvitationSelectionController case InvitationFilter.public: return l10n.public; case InvitationFilter.participants: - return l10n.classRoster; + return l10n.participants; } } diff --git a/lib/pangea/chat_settings/pages/pangea_invitation_selection_view.dart b/lib/pangea/chat_settings/pages/pangea_invitation_selection_view.dart index 0ca4ed566..b7bfc8266 100644 --- a/lib/pangea/chat_settings/pages/pangea_invitation_selection_view.dart +++ b/lib/pangea/chat_settings/pages/pangea_invitation_selection_view.dart @@ -72,7 +72,7 @@ class PangeaInvitationSelectionView extends StatelessWidget { ], ), onPressed: () => context.go( - room.isSpace ? "/rooms?spaceId=${room.id}" : "/rooms/${room.id}", + room.isSpace ? "/rooms/spaces/${room.id}/details" : "/rooms/${room.id}", ), ); diff --git a/lib/pangea/chat_settings/pages/pangea_room_details.dart b/lib/pangea/chat_settings/pages/pangea_room_details.dart new file mode 100644 index 000000000..914fefa4a --- /dev/null +++ b/lib/pangea/chat_settings/pages/pangea_room_details.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; + +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/config/themes.dart'; +import 'package:fluffychat/l10n/l10n.dart'; +import 'package:fluffychat/pages/chat_details/chat_details.dart'; +import 'package:fluffychat/pangea/chat_settings/pages/chat_details_content.dart'; +import 'package:fluffychat/pangea/chat_settings/pages/space_details_content.dart'; +import 'package:fluffychat/widgets/layouts/max_width_body.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +class PangeaRoomDetailsView extends StatelessWidget { + final ChatDetailsController controller; + + const PangeaRoomDetailsView(this.controller, {super.key}); + + @override + Widget build(BuildContext context) { + final room = Matrix.of(context).client.getRoomById(controller.roomId!); + if (room == null || room.membership == Membership.leave) { + return Scaffold( + appBar: AppBar( + title: Text(L10n.of(context).oopsSomethingWentWrong), + ), + body: Center( + child: Text(L10n.of(context).youAreNoLongerParticipatingInThisChat), + ), + ); + } + + final isColumnMode = FluffyThemes.isColumnMode(context); + return StreamBuilder( + stream: room.client.onRoomState.stream + .where((update) => update.roomId == room.id), + builder: (context, snapshot) { + var members = room.getParticipants().toList() + ..sort((b, a) => a.powerLevel.compareTo(b.powerLevel)); + members = members.take(10).toList(); + return Scaffold( + appBar: room.isSpace + ? null + : AppBar( + leading: controller.widget.embeddedCloseButton ?? + const Center(child: BackButton()), + ), + body: Padding( + padding: EdgeInsetsGeometry.symmetric( + vertical: isColumnMode ? 30.0 : 12.0, + horizontal: isColumnMode ? 50.0 : 8.0, + ), + child: MaxWidthBody( + maxWidth: 900, + showBorder: false, + innerPadding: const EdgeInsets.symmetric(horizontal: 16.0), + withScrolling: !room.isSpace, + child: room.isSpace + ? SpaceDetailsContent(controller, room) + : ChatDetailsContent(controller, room), + ), + ), + ); + }, + ); + } +} diff --git a/lib/pangea/chat_settings/pages/room_details_buttons.dart b/lib/pangea/chat_settings/pages/room_details_buttons.dart new file mode 100644 index 000000000..39bb17af1 --- /dev/null +++ b/lib/pangea/chat_settings/pages/room_details_buttons.dart @@ -0,0 +1,102 @@ +import 'package:flutter/material.dart'; + +import 'package:fluffychat/pangea/chat_settings/pages/space_details_content.dart'; +import 'package:fluffychat/widgets/hover_builder.dart'; + +class ButtonDetails { + final String title; + final String? description; + final Widget icon; + final VoidCallback? onPressed; + final bool visible; + final bool enabled; + final bool showInMainView; + final bool desctructive; + final SpaceSettingsTabs? tab; + + const ButtonDetails({ + required this.title, + this.description, + required this.icon, + this.visible = true, + this.enabled = true, + this.onPressed, + this.showInMainView = true, + this.desctructive = false, + this.tab, + }); +} + +class RoomDetailsButton extends StatelessWidget { + final bool mini; + final double height; + final ButtonDetails buttonDetails; + + final bool selected; + + const RoomDetailsButton({ + super.key, + required this.buttonDetails, + required this.mini, + required this.height, + this.selected = false, + }); + + @override + Widget build(BuildContext context) { + if (!buttonDetails.visible) { + return const SizedBox(); + } + + return TooltipVisibility( + visible: mini, + child: Tooltip( + message: buttonDetails.title, + child: AbsorbPointer( + absorbing: !buttonDetails.enabled, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: HoverBuilder( + builder: (context, hovered) { + return GestureDetector( + onTap: buttonDetails.onPressed, + child: Opacity( + opacity: buttonDetails.enabled ? 1.0 : 0.5, + child: Container( + alignment: Alignment.center, + height: height, + decoration: BoxDecoration( + color: hovered || selected + ? Theme.of(context) + .colorScheme + .primaryContainer + .withAlpha(200) + : Colors.transparent, + borderRadius: BorderRadius.circular(8), + ), + padding: EdgeInsets.all(mini ? 6 : 12.0), + child: mini + ? buttonDetails.icon + : Column( + spacing: 12.0, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + buttonDetails.icon, + Text( + buttonDetails.title, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 12.0), + ), + ], + ), + ), + ), + ); + }, + ), + ), + ), + ), + ); + } +} diff --git a/lib/pangea/chat_settings/pages/room_participants_widget.dart b/lib/pangea/chat_settings/pages/room_participants_widget.dart new file mode 100644 index 000000000..84af97b7e --- /dev/null +++ b/lib/pangea/chat_settings/pages/room_participants_widget.dart @@ -0,0 +1,281 @@ +import 'package:flutter/material.dart'; + +import 'package:collection/collection.dart'; +import 'package:go_router/go_router.dart'; +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/l10n/l10n.dart'; +import 'package:fluffychat/pangea/analytics_misc/level_display_name.dart'; +import 'package:fluffychat/pangea/bot/utils/bot_name.dart'; +import 'package:fluffychat/pangea/spaces/utils/load_participants_util.dart'; +import 'package:fluffychat/widgets/avatar.dart'; +import 'package:fluffychat/widgets/hover_builder.dart'; +import 'package:fluffychat/widgets/member_actions_popup_menu_button.dart'; + +class RoomParticipantsSection extends StatelessWidget { + final Room room; + + const RoomParticipantsSection({ + required this.room, + super.key, + }); + + final double _width = 100.0; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return LoadParticipantsUtil( + space: room, + builder: (participantsLoader) { + final filteredParticipants = participantsLoader.sortedParticipants(); + + final originalLeaders = filteredParticipants.take(3).toList(); + filteredParticipants.sort((a, b) { + // always sort bot to the end + final aIsBot = a.id == BotName.byEnvironment; + final bIsBot = b.id == BotName.byEnvironment; + if (aIsBot && !bIsBot) { + return 1; + } else if (bIsBot && !aIsBot) { + return -1; + } + + // put knocking users at the front + if (a.membership == Membership.knock && + b.membership != Membership.knock) { + return -1; + } else if (b.membership == Membership.knock && + a.membership != Membership.knock) { + return 1; + } + + // then invited users + if (a.membership == Membership.invite && + b.membership != Membership.invite) { + return -1; + } else if (b.membership == Membership.invite && + a.membership != Membership.invite) { + return 1; + } + + // then admins + if (a.powerLevel == 100 && b.powerLevel != 100) { + return -1; + } else if (b.powerLevel == 100 && a.powerLevel != 100) { + return 1; + } + + return 0; + }); + + return Wrap( + spacing: 8.0, + alignment: WrapAlignment.center, + runAlignment: WrapAlignment.center, + children: [...filteredParticipants, null].mapIndexed((index, user) { + if (user == null) { + return room.canInvite + ? MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () => context.go( + "/rooms/${room.id}/details/invite", + ), + child: HoverBuilder( + builder: (context, hovered) { + return Container( + decoration: BoxDecoration( + color: hovered + ? Theme.of(context) + .colorScheme + .primary + .withAlpha(50) + : Colors.transparent, + borderRadius: BorderRadius.circular(8), + ), + padding: + const EdgeInsets.symmetric(vertical: 12.0), + width: _width, + child: Column( + spacing: 4.0, + children: [ + const Padding( + padding: EdgeInsets.all(12.0), + child: Icon( + Icons.person_add_outlined, + size: 50.0, + ), + ), + Text( + L10n.of(context).invite, + style: const TextStyle(fontSize: 16.0), + ), + ], + ), + ); + }, + ), + ), + ) + : const SizedBox(); + } + final permissionBatch = user.powerLevel >= 100 + ? L10n.of(context).admin + : user.powerLevel >= 50 + ? L10n.of(context).moderator + : ''; + + final membershipBatch = switch (user.membership) { + Membership.ban => null, + Membership.invite => L10n.of(context).invited, + Membership.join => null, + Membership.knock => L10n.of(context).knocking, + Membership.leave => null, + }; + + final publicProfile = participantsLoader.getAnalyticsProfile( + user.id, + ); + + final leaderIndex = originalLeaders.indexOf(user); + LinearGradient? gradient; + if (leaderIndex != -1) { + gradient = leaderIndex.leaderboardGradient; + if (user.id == BotName.byEnvironment || + publicProfile == null || + publicProfile.level == null) { + gradient = null; + } + } + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 12.0), + child: SizedBox( + width: _width, + child: Opacity( + opacity: user.membership == Membership.join ? 1.0 : 0.5, + child: Column( + spacing: 4.0, + children: [ + Stack( + alignment: Alignment.center, + children: [ + if (gradient != null) + CircleAvatar( + radius: _width / 2, + child: Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: gradient, + ), + ), + ) + else + SizedBox( + height: _width, + width: _width, + ), + Builder( + builder: (context) { + return MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () => showMemberActionsPopupMenu( + context: context, + user: user, + ), + child: Center( + child: Avatar( + mxContent: user.avatarUrl, + name: user.calcDisplayname(), + size: _width - 6.0, + presenceUserId: user.id, + presenceOffset: const Offset(0, 0), + presenceSize: 18.0, + ), + ), + ), + ); + }, + ), + ], + ), + Text( + user.calcDisplayname(), + style: theme.textTheme.labelLarge?.copyWith( + color: theme.colorScheme.primary, + fontWeight: FontWeight.bold, + ), + overflow: TextOverflow.ellipsis, + ), + Container( + height: 20.0, + alignment: Alignment.center, + child: LevelDisplayName( + userId: user.id, + textStyle: theme.textTheme.labelSmall, + ), + ), + Container( + height: 24.0, + alignment: Alignment.center, + child: membershipBatch != null + ? Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 4, + ), + decoration: BoxDecoration( + color: theme.colorScheme.secondaryContainer, + borderRadius: BorderRadius.circular( + AppConfig.borderRadius, + ), + ), + child: Text( + membershipBatch, + style: theme.textTheme.labelSmall?.copyWith( + color: + theme.colorScheme.onSecondaryContainer, + ), + ), + ) + : permissionBatch.isNotEmpty + ? Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 4, + ), + decoration: BoxDecoration( + color: user.powerLevel >= 100 + ? theme.colorScheme.tertiary + : theme.colorScheme.tertiaryContainer, + borderRadius: BorderRadius.circular( + AppConfig.borderRadius, + ), + ), + child: Text( + permissionBatch, + style: + theme.textTheme.labelSmall?.copyWith( + color: user.powerLevel >= 100 + ? theme.colorScheme.onTertiary + : theme.colorScheme + .onTertiaryContainer, + ), + ), + ) + : null, + ), + ], + ), + ), + ), + ); + }).toList(), + ); + }, + ); + } +} diff --git a/lib/pangea/chat_settings/pages/space_details_button_row.dart b/lib/pangea/chat_settings/pages/space_details_button_row.dart new file mode 100644 index 000000000..2ca0c62b8 --- /dev/null +++ b/lib/pangea/chat_settings/pages/space_details_button_row.dart @@ -0,0 +1,126 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/config/themes.dart'; +import 'package:fluffychat/l10n/l10n.dart'; +import 'package:fluffychat/pages/chat_details/chat_details.dart'; +import 'package:fluffychat/pangea/chat_settings/pages/room_details_buttons.dart'; +import 'package:fluffychat/pangea/chat_settings/pages/space_details_content.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +class SpaceDetailsButtonRow extends StatefulWidget { + final SpaceSettingsTabs? selectedTab; + final Function(SpaceSettingsTabs) onTabSelected; + final List buttons; + + final ChatDetailsController controller; + final Room room; + + const SpaceDetailsButtonRow({ + super.key, + required this.selectedTab, + required this.onTabSelected, + required this.buttons, + required this.controller, + required this.room, + }); + + @override + State createState() => SpaceDetailsButtonRowState(); +} + +class SpaceDetailsButtonRowState extends State { + StreamSubscription? notificationChangeSub; + + @override + void initState() { + super.initState(); + notificationChangeSub ??= Matrix.of(context) + .client + .onSync + .stream + .where( + (syncUpdate) => + syncUpdate.accountData?.any( + (accountData) => accountData.type == 'm.push_rules', + ) ?? + false, + ) + .listen( + (u) => setState(() {}), + ); + } + + @override + void dispose() { + notificationChangeSub?.cancel(); + super.dispose(); + } + + final double _buttonHeight = 84.0; + final double _miniButtonWidth = 50.0; + + Room get room => widget.room; + + @override + Widget build(BuildContext context) { + final buttons = widget.buttons + .where( + (button) => button.visible, + ) + .toList(); + + return LayoutBuilder( + builder: (context, constraints) { + final availableWidth = constraints.maxWidth; + final fullButtonCapacity = (availableWidth / 120.0).floor() - 1; + + final mini = fullButtonCapacity < 4; + + final List mainViewButtons = + buttons.where((button) => button.showInMainView).toList(); + final List otherButtons = + buttons.where((button) => !button.showInMainView).toList(); + + return Row( + spacing: FluffyThemes.isColumnMode(context) ? 12.0 : 0.0, + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate(mainViewButtons.length + 1, (index) { + if (index == mainViewButtons.length) { + if (otherButtons.isEmpty) { + return const SizedBox(); + } + + return Expanded( + child: RoomDetailsButton( + mini: mini, + buttonDetails: ButtonDetails( + title: L10n.of(context).more, + icon: const Icon(Icons.more_horiz_outlined), + onPressed: () => + widget.onTabSelected(SpaceSettingsTabs.more), + ), + height: mini ? _miniButtonWidth : _buttonHeight, + selected: widget.selectedTab == SpaceSettingsTabs.more, + ), + ); + } + + final button = mainViewButtons[index]; + return Expanded( + child: RoomDetailsButton( + mini: mini, + buttonDetails: button, + height: mini ? _miniButtonWidth : _buttonHeight, + selected: widget.selectedTab == button.tab, + ), + ); + }), + ); + }, + ); + } +} diff --git a/lib/pangea/chat_settings/pages/space_details_content.dart b/lib/pangea/chat_settings/pages/space_details_content.dart new file mode 100644 index 000000000..73e4b646e --- /dev/null +++ b/lib/pangea/chat_settings/pages/space_details_content.dart @@ -0,0 +1,429 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:go_router/go_router.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:matrix/matrix.dart'; +import 'package:universal_html/html.dart' as html; + +import 'package:fluffychat/config/themes.dart'; +import 'package:fluffychat/l10n/l10n.dart'; +import 'package:fluffychat/pages/chat_details/chat_details.dart'; +import 'package:fluffychat/pangea/chat_settings/pages/room_details_buttons.dart'; +import 'package:fluffychat/pangea/chat_settings/pages/room_participants_widget.dart'; +import 'package:fluffychat/pangea/chat_settings/pages/space_details_button_row.dart'; +import 'package:fluffychat/pangea/chat_settings/widgets/delete_space_dialog.dart'; +import 'package:fluffychat/pangea/common/config/environment.dart'; +import 'package:fluffychat/pangea/course_chats/course_chats_page.dart'; +import 'package:fluffychat/pangea/course_creation/course_info_chip_widget.dart'; +import 'package:fluffychat/pangea/course_settings/course_settings.dart'; +import 'package:fluffychat/pangea/courses/course_plan_builder.dart'; +import 'package:fluffychat/pangea/courses/course_plan_room_extension.dart'; +import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart'; +import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; +import 'package:fluffychat/pangea/space_analytics/space_analytics.dart'; +import 'package:fluffychat/pangea/spaces/constants/space_constants.dart'; +import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart'; +import 'package:fluffychat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart'; +import 'package:fluffychat/widgets/avatar.dart'; +import 'package:fluffychat/widgets/future_loading_dialog.dart'; + +enum SpaceSettingsTabs { + chat, + course, + participants, + analytics, + more, +} + +class SpaceDetailsContent extends StatefulWidget { + final ChatDetailsController controller; + final Room room; + + const SpaceDetailsContent(this.controller, this.room, {super.key}); + + @override + State createState() => SpaceDetailsContentState(); +} + +class SpaceDetailsContentState extends State { + SpaceSettingsTabs? _selectedTab; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + setState( + () => _selectedTab = FluffyThemes.isColumnMode(context) + ? SpaceSettingsTabs.course + : SpaceSettingsTabs.chat, + ); + }); + } + + @override + void didUpdateWidget(covariant SpaceDetailsContent oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.room.id != widget.room.id) { + setState(() { + _selectedTab = FluffyThemes.isColumnMode(context) + ? SpaceSettingsTabs.course + : SpaceSettingsTabs.chat; + }); + } + } + + void setSelectedTab(SpaceSettingsTabs tab) { + setState(() { + _selectedTab = tab; + }); + } + + List get _buttons { + final L10n l10n = L10n.of(context); + return [ + ButtonDetails( + title: l10n.chats, + icon: const Icon(Icons.chat_bubble_outline, size: 30.0), + onPressed: () => setSelectedTab(SpaceSettingsTabs.chat), + visible: !FluffyThemes.isColumnMode(context), + tab: SpaceSettingsTabs.chat, + ), + ButtonDetails( + title: l10n.coursePlan, + icon: const Icon(Icons.map_outlined, size: 30.0), + onPressed: () => setSelectedTab(SpaceSettingsTabs.course), + tab: SpaceSettingsTabs.course, + ), + ButtonDetails( + title: l10n.participants, + icon: const Icon(Icons.group_outlined, size: 30.0), + onPressed: () => setSelectedTab(SpaceSettingsTabs.participants), + tab: SpaceSettingsTabs.participants, + ), + ButtonDetails( + title: l10n.stats, + icon: const Icon(Symbols.bar_chart_4_bars, size: 30.0), + onPressed: () => setSelectedTab(SpaceSettingsTabs.analytics), + enabled: widget.room.isRoomAdmin, + tab: SpaceSettingsTabs.analytics, + ), + ButtonDetails( + title: l10n.invite, + description: l10n.inviteDesc, + icon: const Icon(Icons.person_add_outlined, size: 30.0), + onPressed: () { + String filter = 'knocking'; + if (widget.room.getParticipants([Membership.knock]).isEmpty) { + filter = widget.room.pangeaSpaceParents.isNotEmpty + ? 'space' + : 'contacts'; + } + context.go('/rooms/${widget.room.id}/details/invite?filter=$filter'); + }, + enabled: widget.room.canInvite && !widget.room.isDirectChat, + showInMainView: false, + ), + ButtonDetails( + title: l10n.editCourse, + description: l10n.editCourseDesc, + icon: const Icon(Icons.edit_outlined, size: 30.0), + onPressed: () {}, + visible: false, + enabled: widget.room.canChangeStateEvent(PangeaEventTypes.coursePlan), + showInMainView: false, + ), + ButtonDetails( + title: l10n.permissions, + description: l10n.permissionsDesc, + icon: const Icon(Icons.edit_attributes_outlined, size: 30.0), + onPressed: () => + context.go('/rooms/${widget.room.id}/details/permissions'), + enabled: widget.room.isRoomAdmin && !widget.room.isDirectChat, + showInMainView: false, + ), + ButtonDetails( + title: l10n.access, + description: l10n.accessDesc, + icon: const Icon(Icons.shield_outlined, size: 30.0), + onPressed: () => context.go('/rooms/${widget.room.id}/details/access'), + enabled: widget.room.isRoomAdmin && widget.room.spaceParents.isEmpty, + showInMainView: false, + ), + ButtonDetails( + title: l10n.createGroupChat, + description: l10n.createGroupChatDesc, + icon: const Icon(Symbols.chat_add_on, size: 30.0), + onPressed: widget.controller.addGroupChat, + enabled: widget.room.isRoomAdmin && + widget.room.canChangeStateEvent( + EventTypes.SpaceChild, + ), + showInMainView: false, + ), + ButtonDetails( + title: l10n.leave, + icon: const Icon(Icons.logout_outlined, size: 30.0), + onPressed: () async { + final confirmed = await showOkCancelAlertDialog( + useRootNavigator: false, + context: context, + title: L10n.of(context).areYouSure, + okLabel: L10n.of(context).leave, + cancelLabel: L10n.of(context).no, + message: L10n.of(context).leaveSpaceDescription, + isDestructive: true, + ); + if (confirmed != OkCancelResult.ok) return; + final resp = await showFutureLoadingDialog( + context: context, + future: widget.room.leaveSpace, + ); + if (!resp.isError) { + context.go("/rooms"); + } + }, + enabled: widget.room.membership == Membership.join, + showInMainView: false, + ), + ButtonDetails( + title: l10n.delete, + description: l10n.deleteDesc, + icon: Icon( + Icons.delete_outline, + size: 30.0, + color: Theme.of(context).colorScheme.error, + ), + onPressed: () async { + final resp = await showDialog( + context: context, + builder: (_) => DeleteSpaceDialog(space: widget.room), + ); + + if (resp == true) { + context.go("/rooms"); + } + }, + enabled: widget.room.isRoomAdmin && !widget.room.isDirectChat, + showInMainView: false, + desctructive: true, + ), + ]; + } + + @override + Widget build(BuildContext context) { + final isColumnMode = FluffyThemes.isColumnMode(context); + final displayname = widget.room.getLocalizedDisplayname( + MatrixLocals(L10n.of(context)), + ); + return CoursePlanBuilder( + courseId: widget.room.coursePlan?.uuid, + builder: (context, courseController) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + crossAxisAlignment: isColumnMode + ? CrossAxisAlignment.start + : CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + if (isColumnMode) ...[ + Avatar( + mxContent: widget.room.avatar, + name: displayname, + userId: widget.room.directChatMatrixID, + size: Avatar.defaultSize * 2.5, + borderRadius: widget.room.isSpace + ? BorderRadius.circular(24.0) + : null, + ), + const SizedBox(width: 16.0), + ], + Flexible( + child: Column( + spacing: 12.0, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + displayname, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: isColumnMode ? 32.0 : 12.0, + ), + ), + if (isColumnMode && courseController.course != null) + CourseInfoChips( + courseController.course!, + fontSize: 12.0, + iconSize: 12.0, + ), + ], + ), + ), + if (widget.room.classCode != null) + Padding( + padding: const EdgeInsets.only(left: 16.0), + child: PopupMenuButton( + child: const Icon(Symbols.upload), + onSelected: (value) async { + final spaceCode = widget.room.classCode!; + String toCopy = spaceCode; + if (value == 0) { + final String initialUrl = kIsWeb + ? html.window.origin! + : Environment.frontendURL; + toCopy = + "$initialUrl/#/join_with_link?${SpaceConstants.classCode}=${widget.room.classCode}"; + } + + await Clipboard.setData(ClipboardData(text: toCopy)); + ScaffoldMessenger.of( + context, + ).showSnackBar( + SnackBar( + content: Text( + L10n.of(context).copiedToClipboard, + ), + ), + ); + }, + itemBuilder: (BuildContext context) => + >[ + PopupMenuItem( + value: 0, + child: ListTile( + title: Text(L10n.of(context).shareSpaceLink), + contentPadding: const EdgeInsets.all(0), + ), + ), + PopupMenuItem( + value: 1, + child: ListTile( + title: Text( + L10n.of(context) + .shareInviteCode(widget.room.classCode!), + ), + contentPadding: const EdgeInsets.all(0), + ), + ), + ], + ), + ), + ], + ), + SizedBox(height: isColumnMode ? 24.0 : 12.0), + SpaceDetailsButtonRow( + controller: widget.controller, + room: widget.room, + selectedTab: _selectedTab, + onTabSelected: setSelectedTab, + buttons: _buttons, + ), + SizedBox(height: isColumnMode ? 30.0 : 14.0), + Expanded( + child: Builder( + builder: (context) { + switch (_selectedTab) { + case SpaceSettingsTabs.chat: + return CourseChats( + widget.room.id, + activeChat: null, + client: widget.room.client, + ); + case SpaceSettingsTabs.course: + return SingleChildScrollView( + child: CourseSettings( + courseController, + room: widget.room, + ), + ); + case SpaceSettingsTabs.participants: + return SingleChildScrollView( + child: RoomParticipantsSection(room: widget.room), + ); + case SpaceSettingsTabs.analytics: + return SingleChildScrollView( + child: Center( + child: SpaceAnalytics(roomId: widget.room.id), + ), + ); + case SpaceSettingsTabs.more: + final buttons = _buttons + .where( + (b) => !b.showInMainView && b.visible, + ) + .toList(); + + return SingleChildScrollView( + child: Column( + children: [ + if (courseController.course != null) ...[ + Text( + courseController.course!.description, + style: TextStyle( + fontSize: isColumnMode ? 16.0 : 12.0, + ), + ), + SizedBox(height: isColumnMode ? 30.0 : 14.0), + ], + Column( + spacing: 10.0, + mainAxisSize: MainAxisSize.min, + children: buttons.map((b) { + return Opacity( + opacity: b.enabled ? 1.0 : 0.5, + child: ListTile( + title: Text( + b.title, + style: TextStyle( + fontSize: 12.0, + color: b.desctructive + ? Theme.of(context) + .colorScheme + .error + : null, + ), + ), + subtitle: b.description != null + ? Text( + b.description!, + style: TextStyle( + fontSize: 8.0, + color: b.desctructive + ? Theme.of(context) + .colorScheme + .error + : null, + ), + ) + : null, + leading: b.icon, + onTap: b.enabled + ? () { + b.onPressed?.call(); + } + : null, + ), + ); + }).toList(), + ), + ], + ), + ); + case null: + return const SizedBox(); + } + }, + ), + ), + ], + ); + }, + ); + } +} diff --git a/lib/pangea/common/constants/model_keys.dart b/lib/pangea/common/constants/model_keys.dart index 3cfec96e3..20ef0f00e 100644 --- a/lib/pangea/common/constants/model_keys.dart +++ b/lib/pangea/common/constants/model_keys.dart @@ -166,6 +166,7 @@ class ModelKey { static const String activityPlanBookmarkId = "activity_id"; static const String activityPlanEndAt = "end_at"; static const String activityPlanDuration = "duration"; + static const String activityPlanTopicId = "topic_id"; static const String activityRequestTopic = "topic"; static const String activityRequestMode = "mode"; diff --git a/lib/pangea/common/widgets/pangea_side_view.dart b/lib/pangea/common/widgets/pangea_side_view.dart deleted file mode 100644 index 46c2f0feb..000000000 --- a/lib/pangea/common/widgets/pangea_side_view.dart +++ /dev/null @@ -1,63 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:go_router/go_router.dart'; - -import 'package:fluffychat/config/app_config.dart'; -import 'package:fluffychat/config/themes.dart'; -import 'package:fluffychat/pangea/analytics_page/analytics_page_constants.dart'; -import 'package:fluffychat/pangea/find_your_people/find_your_people_constants.dart'; -import 'package:fluffychat/widgets/navigation_rail.dart'; - -class PangeaSideView extends StatelessWidget { - final String? path; - const PangeaSideView({ - super.key, - required this.path, - }); - - String get _asset { - const defaultAsset = FindYourPeopleConstants.sideBearFileName; - if (path == null || path!.isEmpty) return defaultAsset; - - if (path!.contains('analytics')) { - return AnalyticsPageConstants.dinoBotFileName; - } - - return defaultAsset; - } - - @override - Widget build(BuildContext context) { - return Row( - children: [ - if (FluffyThemes.isColumnMode(context) || - AppConfig.displayNavigationRail) ...[ - SpacesNavigationRail( - activeSpaceId: null, - onGoToChats: () => context.go('/rooms'), - onGoToSpaceId: (spaceId) => context.go('/rooms?spaceId=$spaceId'), - ), - Container( - color: Colors.transparent, - width: 1, - ), - ], - Expanded( - child: Center( - child: SizedBox( - width: 250.0, - child: CachedNetworkImage( - imageUrl: "${AppConfig.assetsBaseURL}/$_asset", - errorWidget: (context, url, error) => const SizedBox(), - placeholder: (context, url) => const Center( - child: CircularProgressIndicator.adaptive(), - ), - ), - ), - ), - ), - ], - ); - } -} diff --git a/lib/pangea/course_chats/course_chats_page.dart b/lib/pangea/course_chats/course_chats_page.dart new file mode 100644 index 000000000..7f61e5b1d --- /dev/null +++ b/lib/pangea/course_chats/course_chats_page.dart @@ -0,0 +1,851 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import 'package:collection/collection.dart'; +import 'package:go_router/go_router.dart'; +import 'package:matrix/matrix.dart' as sdk; +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/l10n/l10n.dart'; +import 'package:fluffychat/pages/chat_list/chat_list.dart'; +import 'package:fluffychat/pangea/chat_settings/constants/pangea_room_types.dart'; +import 'package:fluffychat/pangea/chat_settings/utils/delete_room.dart'; +import 'package:fluffychat/pangea/chat_settings/widgets/delete_space_dialog.dart'; +import 'package:fluffychat/pangea/common/utils/error_handler.dart'; +import 'package:fluffychat/pangea/course_chats/course_chats_view.dart'; +import 'package:fluffychat/pangea/courses/course_plan_model.dart'; +import 'package:fluffychat/pangea/courses/course_plan_room_extension.dart'; +import 'package:fluffychat/pangea/extensions/join_rule_extension.dart'; +import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; +import 'package:fluffychat/pangea/public_spaces/public_room_bottom_sheet.dart'; +import 'package:fluffychat/pangea/spaces/constants/space_constants.dart'; +import 'package:fluffychat/utils/localized_exception_extension.dart'; +import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart'; +import 'package:fluffychat/widgets/adaptive_dialogs/adaptive_dialog_action.dart'; +import 'package:fluffychat/widgets/adaptive_dialogs/show_modal_action_popup.dart'; +import 'package:fluffychat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart'; +import 'package:fluffychat/widgets/avatar.dart'; +import 'package:fluffychat/widgets/future_loading_dialog.dart'; + +class CourseChats extends StatefulWidget { + final Client client; + final String roomId; + final String? activeChat; + + const CourseChats( + this.roomId, { + super.key, + required this.activeChat, + required this.client, + }); + + @override + State createState() => CourseChatsController(); +} + +class CourseChatsController extends State { + String get roomId => widget.roomId; + Room? get room => widget.client.getRoomById(widget.roomId); + + List? discoveredChildren; + StreamSubscription? _roomSubscription; + String? _nextBatch; + bool noMoreRooms = false; + bool isLoading = false; + + CoursePlanModel? course; + String? selectedTopicId; + + @override + void initState() { + // load full participant list into memory to ensure widgets + // that rely on full participants list work as expected + final room = widget.client.getRoomById(widget.roomId); + room?.requestParticipants().then((_) { + if (mounted) setState(() {}); + }); + + loadHierarchy(reload: true); + + // Listen for changes to the activeSpace's hierarchy, + // and reload the hierarchy when they come through + _roomSubscription ??= widget.client.onSync.stream + .where(_hasHierarchyUpdate) + .listen((update) => loadHierarchy(reload: true)); + super.initState(); + } + + @override + void didUpdateWidget(covariant CourseChats oldWidget) { + // initState doesn't re-run when navigating between spaces + // via the navigation rail, so this accounts for that + super.didUpdateWidget(oldWidget); + if (oldWidget.roomId != widget.roomId) { + discoveredChildren = null; + _nextBatch = null; + noMoreRooms = false; + + loadHierarchy(reload: true); + } + } + + @override + void dispose() { + _roomSubscription?.cancel(); + super.dispose(); + } + + void setCourse(CoursePlanModel? course) { + setState(() { + this.course = course; + }); + } + + void setSelectedTopicId(String topicID) { + setState(() { + selectedTopicId = topicID; + }); + } + + int get _selectedTopicIndex => + course?.topics.indexWhere((t) => t.uuid == selectedTopicId) ?? -1; + + bool get canMoveLeft => _selectedTopicIndex > 0; + bool get canMoveRight { + if (course == null) return false; + final endIndex = + room?.ownCurrentTopicIndex(course!) ?? (course!.topics.length - 1); + return _selectedTopicIndex < endIndex; + } + + void moveLeft() { + if (canMoveLeft) { + setSelectedTopicId(course!.topics[_selectedTopicIndex - 1].uuid); + } + } + + void moveRight() { + if (canMoveRight) { + setSelectedTopicId(course!.topics[_selectedTopicIndex + 1].uuid); + } + } + + Topic? get selectedTopic => course?.topics.firstWhereOrNull( + (topic) => topic.uuid == selectedTopicId, + ); + + Future _joinDefaultChats() async { + if (discoveredChildren == null) return; + final found = List.from(discoveredChildren!); + + final List joinFutures = []; + for (final chunk in found) { + if (chunk.canonicalAlias == null) continue; + final alias = chunk.canonicalAlias!; + + final isDefaultChat = (alias.localpart ?? '') + .startsWith(SpaceConstants.announcementsChatAlias) || + (alias.localpart ?? '') + .startsWith(SpaceConstants.introductionChatAlias); + + if (!isDefaultChat) continue; + + joinFutures.add( + widget.client.joinRoom(alias).then((_) { + discoveredChildren?.remove(chunk); + }).catchError((e, s) { + ErrorHandler.logError( + e: e, + s: s, + data: { + 'alias': alias, + 'spaceId': widget.roomId, + }, + ); + return null; + }), + ); + } + + if (joinFutures.isNotEmpty) { + await Future.wait(joinFutures); + } + } + + Future loadHierarchy({reload = false}) async { + final room = widget.client.getRoomById(widget.roomId); + if (room == null) return; + + if (mounted) setState(() => isLoading = true); + + try { + await _loadHierarchy(activeSpace: room, reload: reload); + await _joinDefaultChats(); + } catch (e, s) { + Logs().w('Unable to load hierarchy', e, s); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.toLocalizedString(context))), + ); + } + } finally { + if (mounted) { + setState(() => isLoading = false); + } + } + } + + /// Internal logic of loadHierarchy. It will load the hierarchy of + /// the active space id (or specified spaceId). + /// If [reload] is true, it will reload the entire hierarchy (used when room + /// is added/removed from the space) + /// If [reload] is false, it will load the next set of rooms + Future _loadHierarchy({ + required Room activeSpace, + bool reload = false, + }) async { + // Load all of the space's state events. Space Child events + // are used to filtering out unsuggested, unjoined rooms. + await activeSpace.postLoad(); + + // The current number of rooms loaded for this space that are visible in the UI + final int prevLength = !reload ? (discoveredChildren?.length ?? 0) : 0; + + // Failsafe to prevent too many calls to the server in a row + int callsToServer = 0; + + List? currentHierarchy = + discoveredChildren == null || reload + ? null + : List.from(discoveredChildren!); + String? currentNextBatch = reload ? null : _nextBatch; + + // Makes repeated calls to the server until 10 new visible rooms have + // been loaded, or there are no rooms left to load. Using a loop here, + // rather than one single call to the endpoint, because some spaces have + // so many invisible rooms (analytics rooms) that it might look like + // pressing the 'load more' button does nothing (Because the only rooms + // coming through from those calls are analytics rooms). + while (callsToServer < 5) { + // if this space has been loaded and there are no more rooms to load, break + if (currentHierarchy != null && currentNextBatch == null) { + break; + } + + // if this space has been loaded and 10 new rooms have been loaded, break + final int currentLength = currentHierarchy?.length ?? 0; + if (currentLength - prevLength >= 10) { + break; + } + + // make the call to the server + final response = await widget.client.getSpaceHierarchy( + widget.roomId, + maxDepth: 1, + from: currentNextBatch, + limit: 100, + ); + callsToServer++; + + if (response.nextBatch == null) { + noMoreRooms = true; + } + + // if rooms have earlier been loaded for this space, add those + // previously loaded rooms to the front of the response list + response.rooms.insertAll( + 0, + currentHierarchy ?? [], + ); + + // finally, set the response to the last response for this space + // and set the current next batch token + currentHierarchy = _filterHierarchyResponse(activeSpace, response.rooms); + currentNextBatch = response.nextBatch; + } + + discoveredChildren = currentHierarchy; + discoveredChildren?.sort(_sortSpaceChildren); + _nextBatch = currentNextBatch; + } + + void onChatTap(Room room) async { + if (room.membership == Membership.invite) { + final theme = Theme.of(context); + final inviteEvent = room.getState( + EventTypes.RoomMember, + room.client.userID!, + ); + final matrixLocals = MatrixLocals(L10n.of(context)); + final action = await showAdaptiveDialog( + barrierDismissible: true, + context: context, + builder: (context) => AlertDialog.adaptive( + title: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 256), + child: Center( + child: Text( + room.getLocalizedDisplayname(matrixLocals), + textAlign: TextAlign.center, + ), + ), + ), + content: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 256, maxHeight: 256), + child: Text( + inviteEvent == null + ? L10n.of(context).inviteForMe + : inviteEvent.content.tryGet('reason') ?? + L10n.of(context).youInvitedBy( + room + .unsafeGetUserFromMemoryOrFallback( + inviteEvent.senderId, + ) + .calcDisplayname(i18n: matrixLocals), + ), + textAlign: TextAlign.center, + ), + ), + actions: [ + AdaptiveDialogAction( + onPressed: () => Navigator.of(context).pop(InviteAction.accept), + bigButtons: true, + child: Text(L10n.of(context).accept), + ), + AdaptiveDialogAction( + onPressed: () => Navigator.of(context).pop(InviteAction.decline), + bigButtons: true, + child: Text( + L10n.of(context).decline, + style: TextStyle(color: theme.colorScheme.error), + ), + ), + AdaptiveDialogAction( + onPressed: () => Navigator.of(context).pop(InviteAction.block), + bigButtons: true, + child: Text( + L10n.of(context).block, + style: TextStyle(color: theme.colorScheme.error), + ), + ), + ], + ), + ); + switch (action) { + case null: + return; + case InviteAction.accept: + break; + case InviteAction.decline: + await showFutureLoadingDialog( + context: context, + future: () => room.leave(), + ); + return; + case InviteAction.block: + final userId = inviteEvent?.senderId; + context.go('/rooms/settings/security/ignorelist', extra: userId); + return; + } + if (!mounted) return; + final joinResult = await showFutureLoadingDialog( + context: context, + future: () async { + final waitForRoom = room.client.waitForRoomInSync( + room.id, + join: true, + ); + await room.join(); + await waitForRoom; + }, + exceptionContext: ExceptionContext.joinRoom, + ); + if (joinResult.error != null) return; + } + + if (room.membership == Membership.ban) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(L10n.of(context).youHaveBeenBannedFromThisChat), + ), + ); + return; + } + + if (room.membership == Membership.leave) { + context.go('/rooms/archive/${room.id}'); + return; + } + + if (room.isSpace) { + context.go("/rooms/spaces/${room.id}/details"); + return; + } + + context.go('/rooms/${room.id}'); + } + + void joinChildRoom(SpaceRoomsChunk item) async { + final space = widget.client.getRoomById(widget.roomId); + final joined = await PublicRoomBottomSheet.show( + context: context, + chunk: item, + via: space?.spaceChildren + .firstWhereOrNull( + (child) => child.roomId == item.roomId, + ) + ?.via, + ); + if (mounted && joined == true) { + setState(() { + discoveredChildren?.remove(item); + }); + } + } + + void chatContextAction( + Room room, + BuildContext posContext, [ + Room? space, + ]) async { + final overlay = + Overlay.of(posContext).context.findRenderObject() as RenderBox; + + final button = posContext.findRenderObject() as RenderBox; + + final position = RelativeRect.fromRect( + Rect.fromPoints( + button.localToGlobal(const Offset(0, -65), ancestor: overlay), + button.localToGlobal( + button.size.bottomRight(Offset.zero) + const Offset(-50, 0), + ancestor: overlay, + ), + ), + Offset.zero & overlay.size, + ); + + final displayname = + room.getLocalizedDisplayname(MatrixLocals(L10n.of(context))); + + final spacesWithPowerLevels = room.client.rooms + .where( + (space) => + space.isSpace && + space.canChangeStateEvent(EventTypes.SpaceChild) && + !space.spaceChildren.any((c) => c.roomId == room.id), + ) + .toList(); + + final action = await showMenu( + context: posContext, + position: position, + items: [ + PopupMenuItem( + value: ChatContextAction.open, + child: Row( + mainAxisSize: MainAxisSize.min, + spacing: 12.0, + children: [ + Avatar( + mxContent: room.avatar, + name: displayname, + userId: room.directChatMatrixID, + ), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 128), + child: Text( + displayname, + style: + TextStyle(color: Theme.of(context).colorScheme.onSurface), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + const PopupMenuDivider(), + if (space != null) + PopupMenuItem( + value: ChatContextAction.goToSpace, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Avatar( + mxContent: space.avatar, + size: Avatar.defaultSize / 2, + name: space.getLocalizedDisplayname(), + userId: space.directChatMatrixID, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + L10n.of(context).goToSpace(space.getLocalizedDisplayname()), + ), + ), + ], + ), + ), + if (room.membership == Membership.join) ...[ + PopupMenuItem( + value: ChatContextAction.mute, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + room.pushRuleState == PushRuleState.notify + ? Icons.notifications_on_outlined + : Icons.notifications_off_outlined, + ), + const SizedBox(width: 12), + Text( + room.pushRuleState == PushRuleState.notify + ? L10n.of(context).notificationsOn + : L10n.of(context).notificationsOff, + ), + ], + ), + ), + PopupMenuItem( + value: ChatContextAction.markUnread, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + room.markedUnread + ? Icons.mark_as_unread + : Icons.mark_as_unread_outlined, + ), + const SizedBox(width: 12), + Text( + room.markedUnread + ? L10n.of(context).markAsRead + : L10n.of(context).markAsUnread, + ), + ], + ), + ), + PopupMenuItem( + value: ChatContextAction.favorite, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + room.isFavourite ? Icons.push_pin : Icons.push_pin_outlined, + ), + const SizedBox(width: 12), + Text( + room.isFavourite + ? L10n.of(context).unpin + : L10n.of(context).pin, + ), + ], + ), + ), + if (spacesWithPowerLevels.isNotEmpty && + room.canChangeStateEvent(EventTypes.SpaceParent)) + PopupMenuItem( + value: ChatContextAction.addToSpace, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.groups_outlined), + const SizedBox(width: 12), + Text(L10n.of(context).addToSpace), + ], + ), + ), + // if the room has a parent for which the user has a high enough power level + // to set parent's space child events, show option to remove the room from the space + if (room.spaceParents.isNotEmpty && + room.canChangeStateEvent(EventTypes.SpaceParent) && + room.pangeaSpaceParents.any( + (r) => + r.canChangeStateEvent(EventTypes.SpaceChild) && + r.id == widget.roomId, + )) + PopupMenuItem( + value: ChatContextAction.removeFromSpace, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.delete_sweep_outlined), + const SizedBox(width: 12), + Text(L10n.of(context).removeFromSpace), + ], + ), + ), + ], + PopupMenuItem( + value: ChatContextAction.leave, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.logout_outlined, + color: Theme.of(context).colorScheme.onErrorContainer, + ), + const SizedBox(width: 12), + Text( + room.membership == Membership.invite + ? L10n.of(context).delete + : L10n.of(context).leave, + style: TextStyle( + color: Theme.of(context).colorScheme.onErrorContainer, + ), + ), + ], + ), + ), + if (room.isRoomAdmin && !room.isDirectChat) + PopupMenuItem( + value: ChatContextAction.delete, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.delete_outlined, + color: Theme.of(context).colorScheme.onErrorContainer, + ), + const SizedBox(width: 12), + Text( + L10n.of(context).delete, + style: TextStyle( + color: Theme.of(context).colorScheme.onErrorContainer, + ), + ), + ], + ), + ), + ], + ); + + if (action == null) return; + if (!mounted) return; + + switch (action) { + case ChatContextAction.open: + onChatTap(room); + return; + case ChatContextAction.goToSpace: + context.go("/rooms/spaces/${space!.id}/details"); + return; + case ChatContextAction.favorite: + await showFutureLoadingDialog( + context: context, + future: () => room.setFavourite(!room.isFavourite), + ); + return; + case ChatContextAction.markUnread: + await showFutureLoadingDialog( + context: context, + future: () => room.markUnread(!room.markedUnread), + ); + return; + case ChatContextAction.mute: + await showFutureLoadingDialog( + context: context, + future: () => room.setPushRuleState( + room.pushRuleState == PushRuleState.notify + ? PushRuleState.mentionsOnly + : PushRuleState.notify, + ), + ); + return; + case ChatContextAction.leave: + final confirmed = await showOkCancelAlertDialog( + context: context, + title: L10n.of(context).areYouSure, + message: room.isSpace + ? L10n.of(context).leaveSpaceDescription + : L10n.of(context).leaveRoomDescription, + okLabel: L10n.of(context).leave, + cancelLabel: L10n.of(context).cancel, + isDestructive: true, + ); + if (confirmed != OkCancelResult.ok) return; + if (!mounted) return; + + final resp = await showFutureLoadingDialog( + context: context, + future: room.isSpace ? room.leaveSpace : room.leave, + ); + if (mounted && !resp.isError) { + context.go("/rooms"); + } + + return; + case ChatContextAction.addToSpace: + final space = await showModalActionPopup( + context: context, + title: L10n.of(context).space, + actions: spacesWithPowerLevels + .map( + (space) => AdaptiveModalAction( + value: space, + label: space + .getLocalizedDisplayname(MatrixLocals(L10n.of(context))), + ), + ) + .toList(), + ); + if (space == null) return; + if (room.isSpace) { + final resp = await showOkCancelAlertDialog( + context: context, + title: L10n.of(context).addSubspaceWarning, + ); + if (resp == OkCancelResult.cancel) return; + } + await showFutureLoadingDialog( + context: context, + future: () => space.addToSpace(room.id), + ); + try { + await space.setSpaceChildAccess(); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(L10n.of(context).accessSettingsWarning), + duration: const Duration(seconds: 10), + ), + ); + } + return; + case ChatContextAction.removeFromSpace: + await showFutureLoadingDialog( + context: context, + future: () async { + final activeSpace = room.client.getRoomById(widget.roomId); + if (activeSpace == null) return; + await activeSpace.removeSpaceChild(room.id); + }, + ); + try { + await room.resetSpaceChildAccess(); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(L10n.of(context).accessSettingsWarning), + duration: const Duration(seconds: 10), + ), + ); + } + return; + case ChatContextAction.delete: + if (room.isSpace) { + final resp = await showDialog( + context: context, + builder: (_) => DeleteSpaceDialog(space: room), + ); + if (resp == true && mounted) { + context.go("/rooms"); + } + } else { + final confirmed = await showOkCancelAlertDialog( + context: context, + title: L10n.of(context).areYouSure, + okLabel: L10n.of(context).delete, + cancelLabel: L10n.of(context).cancel, + isDestructive: true, + message: room.isSpace + ? L10n.of(context).deleteSpaceDesc + : L10n.of(context).deleteChatDesc, + ); + if (confirmed != OkCancelResult.ok) return; + if (!mounted) return; + + final resp = await showFutureLoadingDialog( + context: context, + future: room.delete, + ); + if (mounted && !resp.isError) { + context.go("/rooms/spaces/${widget.roomId}/details"); + } + } + return; + } + } + + bool _includeSpaceChild( + Room space, + SpaceRoomsChunk hierarchyMember, + ) { + if (!mounted) return false; + final bool isAnalyticsRoom = + hierarchyMember.roomType == PangeaRoomTypes.analytics; + + final bool isMember = [Membership.join, Membership.invite].contains( + widget.client.getRoomById(hierarchyMember.roomId)?.membership, + ); + + final bool isSuggested = + space.spaceChildSuggestionStatus[hierarchyMember.roomId] ?? true; + + return !isAnalyticsRoom && (isMember || isSuggested); + } + + List _filterHierarchyResponse( + Room space, + List hierarchyResponse, + ) { + final List filteredChildren = []; + for (final child in hierarchyResponse) { + if (child.roomId == widget.roomId) { + continue; + } + + final room = space.client.getRoomById(child.roomId); + if (room != null && room.membership != Membership.leave) { + // If the room is already joined or invited, skip it + continue; + } + + final isDuplicate = filteredChildren.any( + (filtered) => filtered.roomId == child.roomId, + ); + if (isDuplicate) continue; + + if (_includeSpaceChild(space, child)) { + filteredChildren.add(child); + } + } + return filteredChildren; + } + + /// Used to filter out sync updates with hierarchy updates for the active + /// space so that the view can be auto-reloaded in the room subscription + bool _hasHierarchyUpdate(SyncUpdate update) { + final joinTimeline = update.rooms?.join?[widget.roomId]?.timeline; + final leaveTimeline = update.rooms?.leave?[widget.roomId]?.timeline; + if (joinTimeline == null && leaveTimeline == null) return false; + final bool hasJoinUpdate = joinTimeline?.events?.any( + (event) => event.type == EventTypes.SpaceChild, + ) ?? + false; + final bool hasLeaveUpdate = leaveTimeline?.events?.any( + (event) => event.type == EventTypes.SpaceChild, + ) ?? + false; + return hasJoinUpdate || hasLeaveUpdate; + } + + int _sortSpaceChildren( + SpaceRoomsChunk a, + SpaceRoomsChunk b, + ) { + final bool aIsSpace = a.roomType == 'm.space'; + final bool bIsSpace = b.roomType == 'm.space'; + + if (aIsSpace && !bIsSpace) { + return -1; + } else if (!aIsSpace && bIsSpace) { + return 1; + } + return 0; + } + + @override + Widget build(BuildContext context) => CourseChatsView(this); +} diff --git a/lib/pangea/course_chats/course_chats_view.dart b/lib/pangea/course_chats/course_chats_view.dart new file mode 100644 index 000000000..6b44467bc --- /dev/null +++ b/lib/pangea/course_chats/course_chats_view.dart @@ -0,0 +1,348 @@ +import 'package:flutter/material.dart'; + +import 'package:matrix/matrix.dart' as sdk; +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/config/themes.dart'; +import 'package:fluffychat/l10n/l10n.dart'; +import 'package:fluffychat/pages/chat_list/chat_list_item.dart'; +import 'package:fluffychat/pangea/activity_sessions/activity_room_extension.dart'; +import 'package:fluffychat/pangea/chat_settings/constants/pangea_room_types.dart'; +import 'package:fluffychat/pangea/course_chats/course_chats_page.dart'; +import 'package:fluffychat/pangea/course_chats/unjoined_chat_list_item.dart'; +import 'package:fluffychat/pangea/courses/course_plan_builder.dart'; +import 'package:fluffychat/pangea/courses/course_plan_model.dart'; +import 'package:fluffychat/pangea/courses/course_plan_room_extension.dart'; +import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; +import 'package:fluffychat/utils/stream_extension.dart'; + +class CourseChatsView extends StatelessWidget { + final CourseChatsController controller; + const CourseChatsView( + this.controller, { + super.key, + }); + + @override + Widget build(BuildContext context) { + final room = controller.room; + if (room == null) { + return const Center( + child: Icon( + Icons.search_outlined, + size: 80, + ), + ); + } + + return CoursePlanBuilder( + courseId: room.coursePlan?.uuid, + onFound: (course) { + controller.setCourse(course); + final topic = room.ownCurrentTopic(course); + if (topic != null) controller.setSelectedTopicId(topic.uuid); + }, + builder: (context, courseController) { + final Topic? topic = controller.selectedTopic; + final List activityIds = topic?.activityIds ?? []; + + final childrenIds = + room.spaceChildren.map((c) => c.roomId).whereType().toSet(); + + final joinedChats = []; + final joinedSessions = []; + final joinedRooms = room.client.rooms + .where((room) => childrenIds.remove(room.id)) + .where((room) => !room.isHiddenRoom) + .toList(); + + for (final joinedRoom in joinedRooms) { + if (joinedRoom.isActivitySession) { + if (activityIds.contains(joinedRoom.activityPlan?.bookmarkId)) { + joinedSessions.add(joinedRoom); + } + } else { + joinedChats.add(joinedRoom); + } + } + + final discoveredGroupChats = []; + final discoveredSessions = []; + final discoveredChildren = + controller.discoveredChildren ?? []; + + for (final child in discoveredChildren) { + if (child.roomType?.startsWith(PangeaRoomTypes.activitySession) == + true) { + if (activityIds.contains(child.roomType!.split(":").last)) { + discoveredSessions.add(child); + } + } else { + discoveredGroupChats.add(child); + } + } + + final isColumnMode = FluffyThemes.isColumnMode(context); + + return StreamBuilder( + stream: room.client.onSync.stream + .where((s) => s.hasRoomUpdate) + .rateLimit(const Duration(seconds: 1)), + builder: (context, snapshot) { + return Padding( + padding: isColumnMode + ? const EdgeInsets.symmetric( + vertical: 12.0, + horizontal: 8.0, + ) + : const EdgeInsets.all(0.0), + child: ListView.builder( + shrinkWrap: true, + itemCount: joinedChats.length + + joinedSessions.length + + discoveredGroupChats.length + + discoveredSessions.length + + 5, + itemBuilder: (context, i) { + // courses chats title + if (i == 0) { + if (isColumnMode) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 10.0), + child: Column( + spacing: 12.0, + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 24.0), + const Icon( + Icons.chat_bubble_outline, + size: 30.0, + ), + Text( + L10n.of(context).courseChats, + style: const TextStyle(fontSize: 12.0), + ), + const SizedBox(height: 14.0), + ], + ), + ); + } + + return const SizedBox(); + } + i--; + + // joined group chats + if (i < joinedChats.length) { + final joinedRoom = joinedChats[i]; + return ChatListItem( + joinedRoom, + onTap: () => controller.onChatTap(joinedRoom), + onLongPress: (context) => controller.chatContextAction( + joinedRoom, + context, + ), + activeChat: controller.widget.activeChat == joinedRoom.id, + ); + } + i -= joinedChats.length; + + // unjoined group chats + if (i < discoveredGroupChats.length) { + return UnjoinedChatListItem( + chunk: discoveredGroupChats[i], + onTap: () => + controller.joinChildRoom(discoveredGroupChats[i]), + ); + } + i -= discoveredGroupChats.length; + + if (i == 0) { + if (room.coursePlan == null || + (courseController.course == null && + !courseController.loading)) { + return const SizedBox(); + } + + return Padding( + padding: const EdgeInsets.only( + top: 20.0, + bottom: 16.0, + ), + child: courseController.loading + ? LinearProgressIndicator( + borderRadius: BorderRadius.circular( + AppConfig.borderRadius, + ), + ) + : topic != null + ? Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + MouseRegion( + cursor: controller.canMoveLeft + ? SystemMouseCursors.click + : SystemMouseCursors.basic, + child: GestureDetector( + onTap: controller.canMoveLeft + ? controller.moveLeft + : null, + child: Opacity( + opacity: controller.canMoveLeft + ? 1.0 + : 0.3, + child: const Icon( + Icons.arrow_left, + size: 24.0, + ), + ), + ), + ), + Row( + spacing: 6.0, + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.location_on, + size: 24.0, + ), + Text( + topic.location, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + MouseRegion( + cursor: controller.canMoveRight + ? SystemMouseCursors.click + : SystemMouseCursors.basic, + child: GestureDetector( + onTap: controller.canMoveRight + ? controller.moveRight + : null, + child: Opacity( + opacity: controller.canMoveRight + ? 1.0 + : 0.3, + child: const Icon( + Icons.arrow_right, + size: 24.0, + ), + ), + ), + ), + ], + ) + : const SizedBox(), + ); + } + i--; + + if (i == 0) { + return Padding( + padding: const EdgeInsets.only( + bottom: 4.0, + left: 24.0, + right: 24.0, + ), + child: Row( + spacing: 4.0, + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.event_note_outlined, + size: 14.0, + ), + Text( + L10n.of(context).myActivitySessions, + style: const TextStyle(fontSize: 12.0), + ), + ], + ), + ); + } + i--; + + if (i == 0) { + return joinedSessions.isEmpty && discoveredSessions.isEmpty + ? Padding( + padding: const EdgeInsets.symmetric(vertical: 12.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + L10n.of(context).noSessionsFound, + style: const TextStyle( + fontSize: 12.0, + ), + ), + const Icon(Icons.map_outlined, size: 24.0), + ], + ), + ) + : const SizedBox(); + } + i--; + + // joined activity sessions + if (i < joinedSessions.length) { + final joinedRoom = joinedSessions[i]; + return ChatListItem( + joinedRoom, + onTap: () => controller.onChatTap(joinedRoom), + onLongPress: (context) => controller.chatContextAction( + joinedRoom, + context, + ), + activeChat: controller.widget.activeChat == joinedRoom.id, + ); + } + i -= joinedSessions.length; + + // unjoined activity sessions + if (i < discoveredSessions.length) { + return UnjoinedChatListItem( + chunk: discoveredSessions[i], + onTap: () => controller.joinChildRoom( + discoveredSessions[i], + ), + ); + } + i -= discoveredSessions.length; + + if (controller.noMoreRooms) { + return const SizedBox(); + } + + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12.0, + vertical: 2.0, + ), + child: TextButton( + onPressed: controller.isLoading + ? null + : controller.loadHierarchy, + child: controller.isLoading + ? LinearProgressIndicator( + borderRadius: BorderRadius.circular( + AppConfig.borderRadius, + ), + ) + : Text(L10n.of(context).loadMore), + ), + ); + }, + ), + ); + }, + ); + }, + ); + } +} diff --git a/lib/pangea/course_chats/unjoined_chat_list_item.dart b/lib/pangea/course_chats/unjoined_chat_list_item.dart new file mode 100644 index 000000000..c95407c1a --- /dev/null +++ b/lib/pangea/course_chats/unjoined_chat_list_item.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; + +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/l10n/l10n.dart'; +import 'package:fluffychat/widgets/avatar.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +class UnjoinedChatListItem extends StatelessWidget { + final SpaceRoomsChunk chunk; + final VoidCallback onTap; + const UnjoinedChatListItem({ + super.key, + required this.chunk, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final displayname = + chunk.name ?? chunk.canonicalAlias ?? L10n.of(context).emptyChat; + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 1, + ), + child: Material( + borderRadius: BorderRadius.circular(AppConfig.borderRadius), + clipBehavior: Clip.hardEdge, + child: ListTile( + visualDensity: const VisualDensity(vertical: -0.5), + contentPadding: const EdgeInsets.symmetric(horizontal: 8), + onTap: onTap, + leading: Avatar( + mxContent: chunk.avatarUrl, + name: displayname, + userId: Matrix.of(context) + .client + .getRoomById(chunk.roomId) + ?.directChatMatrixID, + borderRadius: chunk.roomType == 'm.space' + ? BorderRadius.circular( + AppConfig.borderRadius / 2, + ) + : null, + ), + title: Row( + children: [ + Expanded( + child: Text( + displayname, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + Text( + chunk.numJoinedMembers.toString(), + style: TextStyle( + fontSize: 13, + color: Theme.of(context).textTheme.bodyMedium!.color, + ), + ), + const SizedBox(width: 4), + const Icon( + Icons.people_outlined, + size: 14, + ), + ], + ), + subtitle: Text( + chunk.topic ?? + L10n.of(context).countParticipants( + chunk.numJoinedMembers, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ); + } +} diff --git a/lib/pangea/course_creation/course_info_chip_widget.dart b/lib/pangea/course_creation/course_info_chip_widget.dart new file mode 100644 index 000000000..2a5b6b9ff --- /dev/null +++ b/lib/pangea/course_creation/course_info_chip_widget.dart @@ -0,0 +1,101 @@ +import 'package:flutter/material.dart'; + +import 'package:fluffychat/l10n/l10n.dart'; +import 'package:fluffychat/pangea/courses/course_plan_model.dart'; +import 'package:fluffychat/pangea/learning_settings/enums/language_level_type_enum.dart'; + +class CourseInfoChip extends StatelessWidget { + final IconData icon; + final String text; + + final double fontSize; + final double iconSize; + final EdgeInsets? padding; + + const CourseInfoChip({ + super.key, + required this.icon, + required this.text, + required this.fontSize, + required this.iconSize, + this.padding, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: padding ?? EdgeInsets.zero, + child: Row( + spacing: 4.0, + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + size: iconSize, + ), + Text( + text, + style: TextStyle( + fontSize: fontSize, + ), + ), + ], + ), + ); + } +} + +class CourseInfoChips extends StatelessWidget { + final CoursePlanModel course; + final double fontSize; + final double iconSize; + final EdgeInsets? padding; + + const CourseInfoChips( + this.course, { + super.key, + required this.fontSize, + required this.iconSize, + this.padding, + }); + + @override + Widget build(BuildContext context) { + return Wrap( + spacing: 8.0, + runSpacing: 8.0, + alignment: WrapAlignment.center, + children: [ + CourseInfoChip( + icon: Icons.language, + text: + "${course.baseLanguageDisplay} → ${course.targetLanguageDisplay}", + fontSize: fontSize, + iconSize: iconSize, + padding: padding, + ), + CourseInfoChip( + icon: Icons.school, + text: course.cefrLevel.string, + fontSize: fontSize, + iconSize: iconSize, + padding: padding, + ), + CourseInfoChip( + icon: Icons.location_on, + text: L10n.of(context).numModules(course.topics.length), + fontSize: fontSize, + iconSize: iconSize, + padding: padding, + ), + CourseInfoChip( + icon: Icons.event_note_outlined, + text: L10n.of(context).numActivityPlans(course.activities), + fontSize: fontSize, + iconSize: iconSize, + padding: padding, + ), + ], + ); + } +} diff --git a/lib/pangea/course_creation/course_plan_filter_widget.dart b/lib/pangea/course_creation/course_plan_filter_widget.dart new file mode 100644 index 000000000..3ae82cb17 --- /dev/null +++ b/lib/pangea/course_creation/course_plan_filter_widget.dart @@ -0,0 +1,127 @@ +import 'package:flutter/material.dart'; + +import 'package:dropdown_button2/dropdown_button2.dart'; + +import 'package:fluffychat/pangea/common/widgets/dropdown_text_button.dart'; + +class CoursePlanFilter extends StatefulWidget { + final T? value; + final List items; + + final void Function(T?) onChanged; + final String Function(T?) displayname; + + final bool enableSearch; + + final double fontSize; + final double iconSize; + + const CoursePlanFilter({ + super.key, + required this.value, + required this.items, + required this.onChanged, + required this.displayname, + required this.fontSize, + required this.iconSize, + this.enableSearch = false, + }); + + @override + State> createState() => CoursePlanFilterState(); +} + +class CoursePlanFilterState extends State> { + final TextEditingController _searchController = TextEditingController(); + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return DropdownButtonHideUnderline( + child: DropdownButton2( + customButton: Container( + decoration: BoxDecoration( + color: theme.colorScheme.primary, + borderRadius: BorderRadius.circular(12.0), + ), + padding: const EdgeInsets.symmetric( + horizontal: 8.0, + vertical: 2.0, + ), + child: Row( + spacing: 4.0, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + widget.displayname(widget.value), + style: TextStyle( + color: theme.colorScheme.onPrimary, + fontSize: widget.fontSize, + ), + ), + Icon( + Icons.arrow_drop_down, + color: theme.colorScheme.onPrimary, + size: widget.iconSize, + ), + ], + ), + ), + value: widget.value, + items: [null, ...widget.items] + .map( + (item) => DropdownMenuItem( + value: item, + child: DropdownTextButton( + text: item == null ? "" : widget.displayname(item), + isSelected: item == widget.value, + ), + ), + ) + .toList(), + onChanged: widget.onChanged, + buttonStyleData: ButtonStyleData( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(40), + ), + ), + dropdownStyleData: const DropdownStyleData( + width: 250, + ), + dropdownSearchData: widget.enableSearch + ? DropdownSearchData( + searchController: _searchController, + searchInnerWidgetHeight: 50, + searchInnerWidget: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + child: TextField( + autofocus: true, + controller: _searchController, + decoration: const InputDecoration( + prefixIcon: Icon(Icons.search), + ), + ), + ), + searchMatchFn: (item, searchValue) { + final displayName = + widget.displayname(item.value).toLowerCase(); + + final search = searchValue.toLowerCase(); + return displayName.startsWith(search); + }, + ) + : null, + onMenuStateChange: (isOpen) { + if (!isOpen) _searchController.clear(); + }, + ), + ); + } +} diff --git a/lib/pangea/course_creation/course_plan_tile_widget.dart b/lib/pangea/course_creation/course_plan_tile_widget.dart new file mode 100644 index 000000000..ef6342215 --- /dev/null +++ b/lib/pangea/course_creation/course_plan_tile_widget.dart @@ -0,0 +1,108 @@ +import 'package:flutter/material.dart'; + +import 'package:cached_network_image/cached_network_image.dart'; + +import 'package:fluffychat/pangea/course_creation/course_info_chip_widget.dart'; +import 'package:fluffychat/pangea/courses/course_plan_model.dart'; +import 'package:fluffychat/widgets/hover_builder.dart'; + +class CoursePlanTile extends StatelessWidget { + final CoursePlanModel course; + final VoidCallback onTap; + + final double titleFontSize; + final double chipFontSize; + final double chipIconSize; + + const CoursePlanTile({ + super.key, + required this.course, + required this.onTap, + required this.titleFontSize, + required this.chipFontSize, + required this.chipIconSize, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return HoverBuilder( + builder: (context, hovered) { + return MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: onTap, + child: Container( + decoration: BoxDecoration( + color: + hovered ? theme.colorScheme.onSurface.withAlpha(10) : null, + ), + child: Row( + spacing: 4.0, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(10.0), + child: course.imageUrl != null + ? CachedNetworkImage( + width: 40.0, + height: 40.0, + fit: BoxFit.cover, + imageUrl: course.imageUrl!, + placeholder: (context, url) { + return const Center( + child: CircularProgressIndicator(), + ); + }, + errorWidget: (context, url, error) { + return Container( + width: 40.0, + height: 40.0, + decoration: BoxDecoration( + color: theme.colorScheme.secondary, + ), + ); + }, + ) + : Container( + width: 40.0, + height: 40.0, + decoration: BoxDecoration( + color: theme.colorScheme.secondary, + ), + ), + ), + Flexible( + child: Column( + spacing: 4.0, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + course.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: titleFontSize, + ), + ), + CourseInfoChips( + course, + padding: const EdgeInsets.symmetric( + horizontal: 4.0, + vertical: 2.0, + ), + fontSize: chipFontSize, + iconSize: chipIconSize, + ), + ], + ), + ), + ], + ), + ), + ), + ); + }, + ); + } +} diff --git a/lib/pangea/course_creation/new_course_page.dart b/lib/pangea/course_creation/new_course_page.dart new file mode 100644 index 000000000..5bdea6781 --- /dev/null +++ b/lib/pangea/course_creation/new_course_page.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; + +import 'package:fluffychat/pangea/course_creation/new_course_view.dart'; +import 'package:fluffychat/pangea/courses/course_plan_model.dart'; +import 'package:fluffychat/pangea/courses/course_repo.dart'; +import 'package:fluffychat/pangea/learning_settings/enums/language_level_type_enum.dart'; +import 'package:fluffychat/pangea/learning_settings/models/language_model.dart'; + +class NewCourse extends StatefulWidget { + const NewCourse({super.key}); + + @override + State createState() => NewCourseController(); +} + +class NewCourseController extends State { + bool loading = true; + Object? error; + + List courses = []; + + LanguageLevelTypeEnum? languageLevelFilter; + LanguageModel? instructionLanguageFilter; + LanguageModel? targetLanguageFilter; + + @override + void initState() { + super.initState(); + _loadCourses(); + } + + CourseFilter get _filter { + return CourseFilter( + targetLanguage: targetLanguageFilter, + languageOfInstructions: instructionLanguageFilter, + cefrLevel: languageLevelFilter, + ); + } + + void setLanguageLevelFilter(LanguageLevelTypeEnum? level) { + languageLevelFilter = level; + _loadCourses(); + } + + void setInstructionLanguageFilter(LanguageModel? language) { + instructionLanguageFilter = language; + _loadCourses(); + } + + void setTargetLanguageFilter(LanguageModel? language) { + targetLanguageFilter = language; + _loadCourses(); + } + + Future _loadCourses() async { + try { + setState(() => loading = true); + courses = await CourseRepo.search(filter: _filter); + } catch (e) { + error = e; + } finally { + setState(() => loading = false); + } + } + + @override + Widget build(BuildContext context) => NewCourseView(this); +} diff --git a/lib/pangea/course_creation/new_course_view.dart b/lib/pangea/course_creation/new_course_view.dart new file mode 100644 index 000000000..bb36b6ae0 --- /dev/null +++ b/lib/pangea/course_creation/new_course_view.dart @@ -0,0 +1,143 @@ +import 'package:flutter/material.dart'; + +import 'package:go_router/go_router.dart'; + +import 'package:fluffychat/l10n/l10n.dart'; +import 'package:fluffychat/pangea/common/widgets/error_indicator.dart'; +import 'package:fluffychat/pangea/course_creation/course_plan_filter_widget.dart'; +import 'package:fluffychat/pangea/course_creation/course_plan_tile_widget.dart'; +import 'package:fluffychat/pangea/course_creation/new_course_page.dart'; +import 'package:fluffychat/pangea/learning_settings/enums/language_level_type_enum.dart'; +import 'package:fluffychat/pangea/learning_settings/models/language_model.dart'; +import 'package:fluffychat/widgets/layouts/max_width_body.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +class NewCourseView extends StatelessWidget { + final NewCourseController controller; + + const NewCourseView(this.controller, {super.key}); + + @override + Widget build(BuildContext context) { + const double titleFontSize = 16.0; + const double descFontSize = 12.0; + + const double iconSize = 12.0; + + return Scaffold( + appBar: AppBar( + title: Text(L10n.of(context).newCourse), + ), + body: Padding( + padding: const EdgeInsets.all(12.0), + child: MaxWidthBody( + showBorder: false, + withScrolling: false, + maxWidth: 500.0, + child: Column( + spacing: 12.0, + children: [ + Text( + L10n.of(context).newCourseSubtitle, + style: const TextStyle( + fontSize: titleFontSize, + ), + ), + Padding( + padding: const EdgeInsetsGeometry.symmetric( + vertical: 4.0, + ), + child: Row( + children: [ + Expanded( + child: Wrap( + spacing: 4.0, + runSpacing: 4.0, + alignment: WrapAlignment.start, + children: [ + CoursePlanFilter( + value: controller.languageLevelFilter, + onChanged: controller.setLanguageLevelFilter, + items: LanguageLevelTypeEnum.values, + displayname: (v) => + v?.string ?? L10n.of(context).cefrLevelLabel, + fontSize: descFontSize, + iconSize: iconSize, + ), + CoursePlanFilter( + value: controller.instructionLanguageFilter, + onChanged: controller.setInstructionLanguageFilter, + items: MatrixState + .pangeaController.pLanguageStore.baseOptions, + displayname: (v) => + v?.getDisplayName(context) ?? + L10n.of(context).languageOfInstructionsLabel, + enableSearch: true, + fontSize: descFontSize, + iconSize: iconSize, + ), + CoursePlanFilter( + value: controller.targetLanguageFilter, + onChanged: controller.setTargetLanguageFilter, + items: MatrixState + .pangeaController.pLanguageStore.targetOptions, + displayname: (v) => + v?.getDisplayName(context) ?? + L10n.of(context).targetLanguageLabel, + enableSearch: true, + fontSize: descFontSize, + iconSize: iconSize, + ), + ], + ), + ), + ], + ), + ), + Builder( + builder: (context) { + if (controller.error != null) { + return Center( + child: ErrorIndicator( + message: L10n.of(context).failedToLoadCourses, + ), + ); + } + + if (controller.loading) { + return const Center( + child: CircularProgressIndicator.adaptive(), + ); + } + + return Expanded( + child: ListView.builder( + itemCount: controller.courses.length, + itemBuilder: (context, index) => Padding( + padding: const EdgeInsetsGeometry.fromLTRB( + 4.0, + 4.0, + 4.0, + 16.0, + ), + child: CoursePlanTile( + course: controller.courses[index], + onTap: () => context.go( + "/rooms/communities/newcourse/${controller.courses[index].uuid}", + ), + titleFontSize: titleFontSize, + chipFontSize: descFontSize, + chipIconSize: iconSize, + ), + ), + ), + ); + }, + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/pangea/course_creation/selected_course_page.dart b/lib/pangea/course_creation/selected_course_page.dart new file mode 100644 index 000000000..2e087a161 --- /dev/null +++ b/lib/pangea/course_creation/selected_course_page.dart @@ -0,0 +1,70 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; + +import 'package:go_router/go_router.dart'; +import 'package:http/http.dart' as http; +import 'package:http/http.dart'; +import 'package:matrix/matrix.dart' as sdk; + +import 'package:fluffychat/l10n/l10n.dart'; +import 'package:fluffychat/pangea/course_creation/selected_course_view.dart'; +import 'package:fluffychat/pangea/courses/course_plan_model.dart'; +import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart'; +import 'package:fluffychat/pangea/spaces/utils/client_spaces_extension.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +class SelectedCourse extends StatefulWidget { + final String courseId; + const SelectedCourse(this.courseId, {super.key}); + + @override + SelectedCourseController createState() => SelectedCourseController(); +} + +class SelectedCourseController extends State { + Future launchCourse(CoursePlanModel course) async { + final client = Matrix.of(context).client; + Uint8List? avatar; + Uri? avatarUrl; + if (course.imageUrl != null) { + try { + final Response response = await http.get(Uri.parse(course.imageUrl!)); + avatar = response.bodyBytes; + avatarUrl = await client.uploadContent(avatar); + } catch (e) { + debugPrint("Error fetching course image: $e"); + } + } + + final roomId = await client.createPangeaSpace( + name: course.title, + introChatName: L10n.of(context).introductions, + announcementsChatName: L10n.of(context).announcements, + visibility: sdk.Visibility.private, + joinRules: sdk.JoinRules.knock, + initialState: [ + sdk.StateEvent( + type: PangeaEventTypes.coursePlan, + content: { + "uuid": course.uuid, + }, + ), + ], + avatar: avatar, + avatarUrl: avatarUrl, + spaceChild: 0, + ); + + if (!mounted) return; + final room = client.getRoomById(roomId); + if (room == null) return; + context.go("/rooms/spaces/${room.id}/details"); + } + + @override + Widget build(BuildContext context) => SelectedCourseView( + courseId: widget.courseId, + launchCourse: launchCourse, + ); +} diff --git a/lib/pangea/course_creation/selected_course_view.dart b/lib/pangea/course_creation/selected_course_view.dart new file mode 100644 index 000000000..2541d6d93 --- /dev/null +++ b/lib/pangea/course_creation/selected_course_view.dart @@ -0,0 +1,320 @@ +import 'package:flutter/material.dart'; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:go_router/go_router.dart'; + +import 'package:fluffychat/l10n/l10n.dart'; +import 'package:fluffychat/pangea/course_creation/course_info_chip_widget.dart'; +import 'package:fluffychat/pangea/courses/course_plan_builder.dart'; +import 'package:fluffychat/pangea/courses/course_plan_model.dart'; +import 'package:fluffychat/widgets/future_loading_dialog.dart'; +import 'package:fluffychat/widgets/layouts/max_width_body.dart'; + +class SelectedCourseView extends StatelessWidget { + final String courseId; + final Future Function(CoursePlanModel course) launchCourse; + const SelectedCourseView({ + super.key, + required this.courseId, + required this.launchCourse, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + const double titleFontSize = 16.0; + const double descFontSize = 12.0; + + const double largeIconSize = 24.0; + const double mediumIconSize = 16.0; + const double smallIconSize = 12.0; + + return Scaffold( + appBar: AppBar( + title: Text(L10n.of(context).newCourse), + ), + body: CoursePlanBuilder( + courseId: courseId, + onNotFound: () => context.go("/rooms/communities/newcourse"), + builder: (context, controller) { + final course = controller.course; + return MaxWidthBody( + showBorder: false, + withScrolling: false, + maxWidth: 500.0, + child: course == null + ? const Center(child: CircularProgressIndicator.adaptive()) + : Stack( + alignment: Alignment.bottomCenter, + children: [ + Padding( + padding: const EdgeInsets.all(12.0), + child: ListView.builder( + itemCount: course.topics.length + 2, + itemBuilder: (context, index) { + if (index == 0) { + return Column( + spacing: 8.0, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(20.0), + child: course.imageUrl != null + ? CachedNetworkImage( + width: 100.0, + height: 100.0, + fit: BoxFit.cover, + imageUrl: course.imageUrl!, + placeholder: (context, url) { + return const Center( + child: + CircularProgressIndicator(), + ); + }, + errorWidget: (context, url, error) { + return Container( + width: 100.0, + height: 100.0, + decoration: BoxDecoration( + color: theme + .colorScheme.secondary, + ), + ); + }, + ) + : Container( + width: 100.0, + height: 100.0, + decoration: BoxDecoration( + color: + theme.colorScheme.secondary, + ), + ), + ), + Text( + course.title, + style: const TextStyle( + fontSize: titleFontSize, + ), + ), + Text( + course.description, + style: + const TextStyle(fontSize: descFontSize), + ), + CourseInfoChips( + course, + fontSize: descFontSize, + iconSize: smallIconSize, + ), + Padding( + padding: const EdgeInsets.only( + top: 4.0, + bottom: 8.0, + ), + child: Row( + spacing: 4.0, + children: [ + const Icon( + Icons.map, + size: largeIconSize, + ), + Text( + L10n.of(context).coursePlan, + style: const TextStyle( + fontSize: titleFontSize, + ), + ), + ], + ), + ), + ], + ); + } + + index--; + + if (index == course.topics.length) { + return const SizedBox(height: 150.0); + } + + final topic = course.topics[index]; + return Padding( + padding: + const EdgeInsets.symmetric(vertical: 4.0), + child: Row( + spacing: 8.0, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(80), + child: topic.imageUrl != null + ? CachedNetworkImage( + width: 40.0, + height: 40.0, + fit: BoxFit.cover, + imageUrl: topic.imageUrl!, + placeholder: (context, url) { + return const Center( + child: + CircularProgressIndicator(), + ); + }, + errorWidget: (context, url, error) { + return Container( + width: 40.0, + height: 40.0, + decoration: BoxDecoration( + color: theme + .colorScheme.secondary, + ), + ); + }, + ) + : Container( + width: 40.0, + height: 40.0, + decoration: BoxDecoration( + color: + theme.colorScheme.secondary, + ), + ), + ), + Flexible( + child: Column( + spacing: 4.0, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + topic.title, + style: const TextStyle( + fontSize: titleFontSize, + ), + ), + Text( + topic.description, + style: const TextStyle( + fontSize: descFontSize, + ), + ), + Padding( + padding: const EdgeInsetsGeometry + .symmetric( + vertical: 2.0, + ), + child: Row( + spacing: 8.0, + mainAxisSize: MainAxisSize.min, + children: [ + CourseInfoChip( + icon: Icons.location_on, + text: topic.location, + fontSize: descFontSize, + iconSize: smallIconSize, + ), + CourseInfoChip( + icon: Icons.event_note_outlined, + text: L10n.of(context) + .numActivityPlans( + topic.activities.length, + ), + fontSize: descFontSize, + iconSize: smallIconSize, + ), + ], + ), + ), + ], + ), + ), + ], + ), + ); + }, + ), + ), + Container( + decoration: BoxDecoration( + color: theme.colorScheme.surface, + border: Border( + top: BorderSide( + color: theme.dividerColor, + width: 1.0, + ), + ), + ), + padding: const EdgeInsets.all(12.0), + child: Column( + spacing: 8.0, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + spacing: 12.0, + children: [ + const Icon( + Icons.edit, + size: mediumIconSize, + ), + Flexible( + child: Text( + L10n.of(context).editCourseLater, + style: + const TextStyle(fontSize: descFontSize), + ), + ), + ], + ), + Row( + spacing: 12.0, + children: [ + const Icon( + Icons.shield, + size: mediumIconSize, + ), + Flexible( + child: Text( + L10n.of(context).newCourseAccess, + style: + const TextStyle(fontSize: descFontSize), + ), + ), + ], + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: theme.colorScheme.primary, + foregroundColor: theme.colorScheme.onPrimary, + padding: const EdgeInsets.symmetric( + vertical: 10.0, + horizontal: 16.0, + ), + ), + onPressed: () => showFutureLoadingDialog( + context: context, + future: () => launchCourse(course), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + L10n.of(context).createCourse, + style: const TextStyle( + fontSize: titleFontSize, + ), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ); + }, + ), + ); + } +} diff --git a/lib/pangea/course_settings/course_settings.dart b/lib/pangea/course_settings/course_settings.dart new file mode 100644 index 000000000..f2e70f422 --- /dev/null +++ b/lib/pangea/course_settings/course_settings.dart @@ -0,0 +1,326 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:collection/collection.dart'; +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/config/themes.dart'; +import 'package:fluffychat/l10n/l10n.dart'; +import 'package:fluffychat/pangea/activity_planner/activity_planner_builder.dart'; +import 'package:fluffychat/pangea/activity_suggestions/activity_suggestion_card.dart'; +import 'package:fluffychat/pangea/activity_suggestions/activity_suggestion_dialog.dart'; +import 'package:fluffychat/pangea/common/widgets/error_indicator.dart'; +import 'package:fluffychat/pangea/course_creation/course_info_chip_widget.dart'; +import 'package:fluffychat/pangea/courses/course_plan_builder.dart'; +import 'package:fluffychat/pangea/courses/course_plan_room_extension.dart'; +import 'package:fluffychat/pangea/spaces/utils/load_participants_util.dart'; +import 'package:fluffychat/widgets/avatar.dart'; + +class CourseSettings extends StatelessWidget { + final Room room; + final CoursePlanController controller; + const CourseSettings( + this.controller, { + super.key, + required this.room, + }); + + @override + Widget build(BuildContext context) { + if (controller.loading) { + return const Center(child: CircularProgressIndicator()); + } + + if (controller.error != null) { + return Center( + child: ErrorIndicator(message: L10n.of(context).failedToLoadCourseInfo), + ); + } + + if (controller.course == null) { + return Center(child: Text(L10n.of(context).noCourseFound)); + } + + final theme = Theme.of(context); + final isColumnMode = FluffyThemes.isColumnMode(context); + final double titleFontSize = isColumnMode ? 32.0 : 12.0; + final double descFontSize = isColumnMode ? 12.0 : 8.0; + final double iconSize = isColumnMode ? 16.0 : 12.0; + + final course = controller.course!; + final currentTopicIndex = room.currentTopicIndex( + room.client.userID!, + course, + ); + final topicsToUsers = room.topicsToUsers(course); + + return Column( + spacing: isColumnMode ? 30.0 : 12.0, + mainAxisSize: MainAxisSize.min, + children: course.topics.mapIndexed((index, topic) { + final unlocked = index <= currentTopicIndex; + final usersInTopic = topicsToUsers[topic.uuid] ?? []; + return AbsorbPointer( + absorbing: !unlocked, + child: Opacity( + opacity: unlocked ? 1.0 : 0.5, + child: Column( + spacing: 12.0, + mainAxisSize: MainAxisSize.min, + children: [ + LayoutBuilder( + builder: (context, constraints) { + return Row( + spacing: 8.0, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Flexible( + child: Row( + spacing: 4.0, + mainAxisSize: MainAxisSize.min, + children: [ + Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(80), + child: topic.imageUrl != null + ? CachedNetworkImage( + width: 54.0, + height: 54.0, + fit: BoxFit.cover, + imageUrl: topic.imageUrl!, + placeholder: (context, url) { + return const Center( + child: + CircularProgressIndicator(), + ); + }, + errorWidget: (context, url, error) { + return Container( + width: 54.0, + height: 54.0, + decoration: BoxDecoration( + color: theme + .colorScheme.secondary, + ), + ); + }, + ) + : Container( + width: 54.0, + height: 54.0, + decoration: BoxDecoration( + color: + theme.colorScheme.secondary, + ), + ), + ), + if (!unlocked) + const Positioned( + bottom: 0, + right: 0, + child: Icon(Icons.lock, size: 24.0), + ), + ], + ), + Flexible( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + topic.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: titleFontSize, + ), + ), + CourseInfoChip( + icon: Icons.location_on, + text: topic.location, + fontSize: descFontSize, + iconSize: iconSize, + ), + if (constraints.maxWidth < 700.0) + Padding( + padding: + const EdgeInsetsGeometry.symmetric( + vertical: 4.0, + ), + child: TopicParticipantList( + room: room, + users: usersInTopic, + avatarSize: + isColumnMode ? 50.0 : 25.0, + overlap: isColumnMode ? 20.0 : 8.0, + ), + ), + ], + ), + ), + ], + ), + ), + if (constraints.maxWidth >= 700.0) + TopicParticipantList( + room: room, + users: usersInTopic, + avatarSize: isColumnMode ? 50.0 : 25.0, + overlap: isColumnMode ? 20.0 : 8.0, + ), + ], + ); + }, + ), + if (unlocked) + SizedBox( + height: 210.0, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: topic.activities.length, + itemBuilder: (context, index) { + final activity = topic.activities[index]; + return Padding( + padding: const EdgeInsets.only(right: 24.0), + child: ActivityPlannerBuilder( + initialActivity: activity, + room: room, + builder: (activityController) { + return ActivitySuggestionCard( + controller: activityController, + onPressed: () { + showDialog( + context: context, + builder: (context) { + return ActivitySuggestionDialog( + controller: activityController, + buttonText: + L10n.of(context).launchToSpace, + ); + }, + ); + }, + width: 120.0, + height: 200.0, + fontSize: 12.0, + fontSizeSmall: 8.0, + iconSize: 8.0, + ); + }, + ), + ); + }, + ), + ), + ], + ), + ), + ); + }).toList(), + ); + } +} + +class TopicParticipantList extends StatelessWidget { + final Room room; + final List users; + final double avatarSize; + final int maxVisible; + final double overlap; + + const TopicParticipantList({ + super.key, + required this.room, + required this.users, + this.avatarSize = 50.0, + this.maxVisible = 6, + this.overlap = 20.0, + }); + + @override + Widget build(BuildContext context) { + final maxWidth = + (avatarSize - overlap) * min(users.length, maxVisible) + overlap; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: maxWidth, + height: avatarSize, + child: LoadParticipantsUtil( + space: room, + builder: (participantsLoader) { + final publicProfiles = Map.fromEntries( + users.map( + (u) => MapEntry( + u.id, + participantsLoader.getAnalyticsProfile(u.id)?.level, + ), + ), + ); + + users.sort((a, b) { + final aLevel = publicProfiles[a.id]; + final bLevel = publicProfiles[b.id]; + if (aLevel != null && bLevel != null) { + return bLevel.compareTo(aLevel); + } + return 0; + }); + + return Stack( + children: users.take(maxVisible).mapIndexed((index, user) { + final level = publicProfiles[user.id]; + final LinearGradient? gradient = + level != null ? index.leaderboardGradient : null; + return Positioned( + left: index * (avatarSize - overlap), + child: Stack( + alignment: Alignment.center, + children: [ + if (gradient != null) + CircleAvatar( + radius: avatarSize / 2, + child: Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: gradient, + ), + ), + ) + else + SizedBox( + height: avatarSize, + width: avatarSize, + ), + Center( + child: Avatar( + mxContent: user.avatarUrl, + name: user.calcDisplayname(), + size: avatarSize - 6.0, + userId: user.id, + ), + ), + ], + ), + ); + }).toList(), + ); + }, + ), + ), + if (users.length > maxVisible) + Text( + L10n.of(context).additionalParticipants(users.length - maxVisible), + style: const TextStyle( + fontSize: 12.0, + ), + ), + ], + ); + } +} diff --git a/lib/pangea/courses/course_plan_builder.dart b/lib/pangea/courses/course_plan_builder.dart new file mode 100644 index 000000000..0976748b2 --- /dev/null +++ b/lib/pangea/courses/course_plan_builder.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; + +import 'package:fluffychat/pangea/courses/course_plan_model.dart'; +import 'package:fluffychat/pangea/courses/course_repo.dart'; + +class CoursePlanBuilder extends StatefulWidget { + final String? courseId; + final VoidCallback? onNotFound; + final Function(CoursePlanModel course)? onFound; + final Widget Function( + BuildContext context, + CoursePlanController controller, + ) builder; + + const CoursePlanBuilder({ + super.key, + required this.courseId, + required this.builder, + this.onNotFound, + this.onFound, + }); + + @override + State createState() => CoursePlanController(); +} + +class CoursePlanController extends State { + bool loading = true; + Object? error; + + CoursePlanModel? course; + + @override + void initState() { + super.initState(); + _loadCourse(); + } + + @override + void didUpdateWidget(covariant CoursePlanBuilder oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.courseId != widget.courseId) { + _loadCourse(); + } + } + + Future _loadCourse() async { + if (widget.courseId == null) { + setState(() { + loading = false; + error = null; + course = null; + }); + return; + } + + try { + setState(() { + loading = true; + error = null; + }); + + course = await CourseRepo.get(widget.courseId!); + course == null + ? widget.onNotFound?.call() + : widget.onFound?.call(course!); + } catch (e) { + error = e; + } finally { + setState(() { + loading = false; + }); + } + } + + @override + Widget build(BuildContext context) => widget.builder(context, this); +} diff --git a/lib/pangea/courses/course_plan_event.dart b/lib/pangea/courses/course_plan_event.dart new file mode 100644 index 000000000..116d28c16 --- /dev/null +++ b/lib/pangea/courses/course_plan_event.dart @@ -0,0 +1,17 @@ +class CoursePlanEvent { + final String uuid; + + CoursePlanEvent({required this.uuid}); + + Map toJson() { + return { + 'uuid': uuid, + }; + } + + factory CoursePlanEvent.fromJson(Map json) { + return CoursePlanEvent( + uuid: json['uuid'] as String, + ); + } +} diff --git a/lib/pangea/courses/course_plan_model.dart b/lib/pangea/courses/course_plan_model.dart new file mode 100644 index 000000000..4d5688362 --- /dev/null +++ b/lib/pangea/courses/course_plan_model.dart @@ -0,0 +1,140 @@ +import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart'; +import 'package:fluffychat/pangea/learning_settings/enums/language_level_type_enum.dart'; +import 'package:fluffychat/pangea/learning_settings/models/language_model.dart'; +import 'package:fluffychat/pangea/learning_settings/utils/p_language_store.dart'; + +/// Represents a topic in the course planner response. +class Topic { + final String title; + final String description; + final String location; + final String uuid; + final String? imageUrl; + + final List activities; + + Topic({ + required this.title, + required this.description, + this.location = "Unknown", + required this.uuid, + List? activities, + this.imageUrl, + }) : activities = activities ?? []; + + /// Deserialize from JSON + factory Topic.fromJson(Map json) { + return Topic( + title: json['title'] as String, + description: json['description'] as String, + location: json['location'] as String? ?? "Unknown", + uuid: json['uuid'] as String, + activities: (json['activities'] as List?) + ?.map( + (e) => ActivityPlanModel.fromJson(e as Map), + ) + .toList() ?? + [], + imageUrl: json['image_url'] as String?, + ); + } + + /// Serialize to JSON + Map toJson() { + return { + 'title': title, + 'description': description, + 'location': location, + 'uuid': uuid, + 'activities': activities.map((e) => e.toJson()).toList(), + 'image_url': imageUrl, + }; + } + + List get activityIds => activities.map((e) => e.bookmarkId).toList(); +} + +/// Represents a course plan in the course planner response. +class CoursePlanModel { + final String targetLanguage; + final String languageOfInstructions; + final LanguageLevelTypeEnum cefrLevel; + + final String title; + final String description; + + final String uuid; + + final List topics; + final String? imageUrl; + + CoursePlanModel({ + required this.targetLanguage, + required this.languageOfInstructions, + required this.cefrLevel, + required this.title, + required this.description, + required this.uuid, + List? topics, + this.imageUrl, + }) : topics = topics ?? []; + + int get activities => + topics.map((t) => t.activities.length).reduce((a, b) => a + b); + + LanguageModel? get targetLanguageModel => + PLanguageStore.byLangCode(targetLanguage); + + LanguageModel? get baseLanguageModel => + PLanguageStore.byLangCode(languageOfInstructions); + + String get targetLanguageDisplay => + targetLanguageModel?.langCode.toUpperCase() ?? + targetLanguage.toUpperCase(); + + String get baseLanguageDisplay => + baseLanguageModel?.langCode.toUpperCase() ?? + languageOfInstructions.toUpperCase(); + + String? topicID(String activityID) { + for (final topic in topics) { + for (final activity in topic.activities) { + if (activity.bookmarkId == activityID) { + return topic.uuid; + } + } + } + return null; + } + + /// Deserialize from JSON + factory CoursePlanModel.fromJson(Map json) { + return CoursePlanModel( + targetLanguage: json['target_language'] as String, + languageOfInstructions: json['language_of_instructions'] as String, + cefrLevel: LanguageLevelTypeEnumExtension.fromString(json['cefr_level']), + title: json['title'] as String, + description: json['description'] as String, + uuid: json['uuid'] as String, + topics: (json['topics'] as List?) + ?.map((e) => Topic.fromJson(e as Map)) + .toList() ?? + [], + imageUrl: json['image_url'] as String?, + ); + } + + /// Serialize to JSON + Map toJson() { + return { + 'target_language': targetLanguage, + 'language_of_instructions': languageOfInstructions, + 'cefr_level': cefrLevel.string, + 'title': title, + 'description': description, + 'uuid': uuid, + 'topics': topics.map((e) => e.toJson()).toList(), + 'image_url': imageUrl, + }; + } +} diff --git a/lib/pangea/courses/course_plan_room_extension.dart b/lib/pangea/courses/course_plan_room_extension.dart new file mode 100644 index 000000000..3a02d9a58 --- /dev/null +++ b/lib/pangea/courses/course_plan_room_extension.dart @@ -0,0 +1,115 @@ +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/pangea/bot/utils/bot_name.dart'; +import 'package:fluffychat/pangea/courses/course_plan_event.dart'; +import 'package:fluffychat/pangea/courses/course_plan_model.dart'; +import 'package:fluffychat/pangea/courses/course_user_event.dart'; +import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart'; + +extension CoursePlanRoomExtension on Room { + CoursePlanEvent? get coursePlan { + final event = getState(PangeaEventTypes.coursePlan); + if (event == null) return null; + return CoursePlanEvent.fromJson(event.content); + } + + CourseUserState? _courseUserState(String userID) { + final event = getState( + PangeaEventTypes.courseUser, + userID, + ); + if (event == null) return null; + return CourseUserState.fromJson(event.content); + } + + CourseUserState? get _ownCourseState => _courseUserState(client.userID!); + + bool _hasCompletedTopic( + String userID, + String topicID, + CoursePlanModel course, + ) { + final state = _courseUserState(userID); + if (state == null) return false; + + final topicIndex = course.topics.indexWhere( + (t) => t.uuid == topicID, + ); + + if (topicIndex == -1) { + throw Exception('Topic not found'); + } + + final activityIds = + course.topics[topicIndex].activities.map((a) => a.bookmarkId).toList(); + return state.completedActivities(topicID).toSet().containsAll(activityIds); + } + + Topic? currentTopic( + String userID, + CoursePlanModel course, + ) { + if (coursePlan == null) return null; + final topicIDs = course.topics.map((t) => t.uuid).toList(); + if (topicIDs.isEmpty) return null; + + final index = topicIDs.indexWhere( + (t) => !_hasCompletedTopic(userID, t, course), + ); + + return index == -1 ? null : course.topics[index]; + } + + Topic? ownCurrentTopic(CoursePlanModel course) => + currentTopic(client.userID!, course); + + int currentTopicIndex( + String userID, + CoursePlanModel course, + ) { + if (coursePlan == null) return -1; + final topicIDs = course.topics.map((t) => t.uuid).toList(); + if (topicIDs.isEmpty) return -1; + + final index = topicIDs.indexWhere( + (t) => !_hasCompletedTopic(userID, t, course), + ); + + return index == -1 ? 0 : index; + } + + int ownCurrentTopicIndex(CoursePlanModel course) => + currentTopicIndex(client.userID!, course); + + Map> topicsToUsers(CoursePlanModel course) { + final Map> topicUserMap = {}; + final users = getParticipants(); + for (final user in users) { + if (user.id == BotName.byEnvironment) continue; + final topicIndex = currentTopicIndex(user.id, course); + if (topicIndex != -1) { + final topicID = course.topics[topicIndex].uuid; + topicUserMap.putIfAbsent(topicID, () => []).add(user); + } + } + return topicUserMap; + } + + Future finishCourseActivity( + String activityID, + String topicID, + ) async { + CourseUserState? state = _ownCourseState; + state ??= CourseUserState( + userID: client.userID!, + completedActivities: {}, + ); + state.completeActivity(activityID, topicID); + await client.setRoomStateWithKey( + id, + PangeaEventTypes.courseUser, + client.userID!, + state.toJson(), + ); + } +} diff --git a/lib/pangea/courses/course_repo.dart b/lib/pangea/courses/course_repo.dart new file mode 100644 index 000000000..d15722949 --- /dev/null +++ b/lib/pangea/courses/course_repo.dart @@ -0,0 +1,97 @@ +import 'package:collection/collection.dart'; +import 'package:get_storage/get_storage.dart'; + +import 'package:fluffychat/pangea/courses/course_plan_model.dart'; +import 'package:fluffychat/pangea/courses/test_courses_json.dart'; +import 'package:fluffychat/pangea/learning_settings/enums/language_level_type_enum.dart'; +import 'package:fluffychat/pangea/learning_settings/models/language_model.dart'; + +class CourseFilter { + final LanguageModel? targetLanguage; + final LanguageModel? languageOfInstructions; + final LanguageLevelTypeEnum? cefrLevel; + + CourseFilter({ + this.targetLanguage, + this.languageOfInstructions, + this.cefrLevel, + }); +} + +class CourseRepo { + static final GetStorage _courseStorage = GetStorage("course_storage"); + + static CoursePlanModel? _getCached(String id) { + final json = _courseStorage.read(id); + if (json != null) { + try { + return CoursePlanModel.fromJson(json); + } catch (e) { + _courseStorage.remove(id); + } + } + return null; + } + + static List _getAllCached() { + final keys = _courseStorage.getKeys(); + return keys + .map((key) => _getCached(key)) + .whereType() + .toList(); + } + + static Future set(CoursePlanModel coursePlan) async { + await _courseStorage.write(coursePlan.uuid, coursePlan.toJson()); + } + + static Future get(String id) async { + final cached = _getCached(id); + if (cached != null) { + return cached; + } + + final resp = await search(); + return resp.firstWhereOrNull((course) => course.uuid == id); + } + + static Future> search({CourseFilter? filter}) async { + final cached = _getAllCached(); + if (cached.isNotEmpty) { + return cached.filtered(filter); + } + + final resp = (courseJson["courses"] as List) + .map((json) => CoursePlanModel.fromJson(json)) + .whereType() + .toList(); + + for (final plan in resp) { + set(plan); + } + + return resp.filtered(filter); + } +} + +extension on List { + List filtered(CourseFilter? filter) { + return where((course) { + final matchesTargetLanguage = filter?.targetLanguage == null || + course.targetLanguage.split("-").first == + filter?.targetLanguage?.langCodeShort; + + final matchesLanguageOfInstructions = + filter?.languageOfInstructions == null || + course.languageOfInstructions.split("-").first == + filter?.languageOfInstructions?.langCodeShort; + + final matchesCefrLevel = + filter?.cefrLevel == null || course.cefrLevel == filter?.cefrLevel; + + return matchesTargetLanguage && + matchesLanguageOfInstructions && + matchesCefrLevel; + }).toList(); + } +} diff --git a/lib/pangea/courses/course_user_event.dart b/lib/pangea/courses/course_user_event.dart new file mode 100644 index 000000000..f2c0078a6 --- /dev/null +++ b/lib/pangea/courses/course_user_event.dart @@ -0,0 +1,45 @@ +class CourseUserState { + final String userID; + final Map> _completedActivities; + + CourseUserState({ + required this.userID, + required Map> completedActivities, + }) : _completedActivities = completedActivities; + + void completeActivity( + String activityID, + String topicID, + ) { + _completedActivities[topicID] ??= []; + if (!_completedActivities[topicID]!.contains(activityID)) { + _completedActivities[topicID]!.add(activityID); + } + } + + List completedActivities(String topicID) { + return _completedActivities[topicID] ?? []; + } + + factory CourseUserState.fromJson(Map json) { + final Map> activities = {}; + final activityEntry = + (json['comp_act_by_topic'] as Map?) ?? {}; + + for (final entry in activityEntry.entries) { + activities[entry.key] = List.from(entry.value); + } + + return CourseUserState( + userID: json['user_id'], + completedActivities: activities, + ); + } + + Map toJson() { + return { + 'user_id': userID, + 'comp_act_by_topic': _completedActivities, + }; + } +} diff --git a/lib/pangea/courses/test_courses_json.dart b/lib/pangea/courses/test_courses_json.dart new file mode 100644 index 000000000..74c166269 --- /dev/null +++ b/lib/pangea/courses/test_courses_json.dart @@ -0,0 +1,5762 @@ +final courseJson = { + "courses": [ + { + "target_language": "en", + "language_of_instructions": "es", + "cefr_level": "B2", + "title": "Curso de inglés de Lena para gerentes de almacén", + "description": + "Este curso está diseñado para gerentes de almacén con nivel B2 que necesitan potenciar sus habilidades de business communication en contexto logístico. A lo largo de 14 módulos aprenderás estructuras gramaticales clave, vocabulario especializado y estrategias para comunicarte eficazmente con supervisores, clientes, proveedores y tu equipo.", + "uuid": "2855bde6-fa0a-42ea-8109-870abc92b8ef", + "topics": [ + { + "title": "Tiempos verbales esenciales", + "description": + "En este módulo vas a reforzar los tiempos verbales que más se usan en reporting y planes: el present perfect, la diferencia entre past simple y present perfect para status updates, future forms (will, going to, present continuous) y los usos del past continuous y past perfect en incident reporting.", + "uuid": "f53a7766-476a-4d7e-a686-6e67085a5fd0", + "activities": [ + { + "activity_id": "Yza5lmtWUsiAFCXNsv8TcOD67HboXAOFpjAN", + "title": "Informe de Inventario en la Tienda", + "learning_objective": + "Puedo usar el present perfect para informar cambios recientes en el inventario de forma clara.", + "instructions": + "En esta actividad, tú y tu compañero/a simularán una conversación entre un gerente de tienda y un empleado. El gerente pedirá un informe sobre los cambios recientes en el inventario, y el empleado responderá utilizando el present perfect.\n\nGerente: Haz preguntas sobre el inventario usando \"Have you...?\" o \"Has there been...?\"\nEjemplo: \"Have you checked the stock of electronics?\"\n\nEmpleado: Responde usando el present perfect para informar sobre los cambios.\nEjemplo: \"Yes, I have checked the electronics. We have sold 5 laptops since yesterday.\"\n\nRecuerda usar expresiones de tiempo como \"since yesterday\", \"in the last week\", o \"recently\" para enfatizar lo reciente de los cambios.\n\nContinúen la conversación discutiendo varios aspectos del inventario de la tienda.", + "vocab": [ + {"lemma": "inventory", "pos": "NOUN"}, + {"lemma": "stock", "pos": "NOUN"}, + {"lemma": "check", "pos": "VERB"}, + {"lemma": "sell", "pos": "VERB"}, + {"lemma": "receive", "pos": "VERB"}, + {"lemma": "restock", "pos": "VERB"}, + {"lemma": "shortage", "pos": "NOUN"}, + {"lemma": "surplus", "pos": "NOUN"}, + {"lemma": "delivery", "pos": "NOUN"}, + {"lemma": "update", "pos": "VERB"}, + ], + "roles": { + "2c0da65f-877f-42f6-aead-4117e16611a7": { + "name": "Gerente", + "id": "2c0da65f-877f-42f6-aead-4117e16611a7", + }, + "6c2fe624-c23b-40d7-a68f-016f33c2a3ca": { + "name": "Empleado", + "id": "6c2fe624-c23b-40d7-a68f-016f33c2a3ca", + }, + }, + "req": { + "topic": "Uso del present perfect en informes de inventario", + "mode": "Roleplay", + "objective": + "Puedo usar el present perfect para informar cambios recientes en el inventario de forma clara.", + "media": "nan", + "activity_cefr_level": "B2", + "language_of_instructions": "es", + "target_language": "en", + "number_of_participants": 2, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + }, + { + "activity_id": "KIlS7aRhqzss2ath7V7XFnkg40H2HgbfNcgv", + "title": "Actualizaciones de Proyecto: Pasado y Presente", + "learning_objective": + "Puedo distinguir y usar past simple y present perfect al dar actualizaciones de estado a mi equipo.", + "instructions": + "En esta actividad, simularán una reunión de actualización de proyecto. Cada uno de ustedes tiene un rol específico en el equipo del proyecto.\n\n1. El Gerente de Proyecto iniciará la reunión pidiendo actualizaciones.\n2. El Desarrollador y el Diseñador responderán con sus actualizaciones utilizando past simple para acciones completadas y present perfect para acciones en curso o con impacto en el presente.\n3. Usen mensajes de voz para comunicarse.\n4. Asegúrense de incluir al menos 3 ejemplos de past simple y 3 de present perfect en sus actualizaciones.\n\nEjemplos:\n- \"I finished the database setup yesterday.\" (Past Simple)\n- \"We have already completed 70% of the design work.\" (Present Perfect)\n- \"The team hasn't resolved all the bugs yet.\" (Present Perfect)\n- \"Did you test the new feature last week?\" (Past Simple)\n\nRecuerden: \n- Past Simple se usa para acciones completadas en un tiempo específico en el pasado.\n- Present Perfect se usa para acciones que comenzaron en el pasado y continúan en el presente, o cuyo resultado es relevante ahora.", + "vocab": [ + {"lemma": "update", "pos": "NOUN"}, + {"lemma": "complete", "pos": "VERB"}, + {"lemma": "resolve", "pos": "VERB"}, + {"lemma": "implement", "pos": "VERB"}, + {"lemma": "progress", "pos": "NOUN"}, + {"lemma": "deadline", "pos": "NOUN"}, + {"lemma": "challenge", "pos": "NOUN"}, + {"lemma": "achieve", "pos": "VERB"}, + {"lemma": "milestone", "pos": "NOUN"}, + {"lemma": "collaborate", "pos": "VERB"}, + ], + "roles": { + "ad5777ee-7875-4790-a808-c6c3148578c4": { + "name": "Gerente de Proyecto", + "id": "ad5777ee-7875-4790-a808-c6c3148578c4", + }, + "38b3b8b9-4b40-4dbc-ab93-c4b3aea3e383": { + "name": "Desarrollador", + "id": "38b3b8b9-4b40-4dbc-ab93-c4b3aea3e383", + }, + "0c3055e8-f61e-497e-b839-4e4e1f5f0a3b": { + "name": "Diseñador", + "id": "0c3055e8-f61e-497e-b839-4e4e1f5f0a3b", + }, + }, + "req": { + "topic": + "Past simple vs. present perfect en actualizaciones de estado", + "mode": "Conversation", + "objective": + "Puedo distinguir y usar past simple y present perfect al dar actualizaciones de estado a mi equipo.", + "media": "voice_messages", + "activity_cefr_level": "B2", + "language_of_instructions": "es", + "target_language": "en", + "number_of_participants": 3, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + }, + { + "activity_id": "ZCPVCnieb9VBee7SLGIgnIxpYtdpXWnUAfRq", + "title": "Planificación del Proyecto Escolar", + "learning_objective": + "Puedo elegir y usar correctamente will, going to o present continuous para planificar tareas y proyectos.", + "instructions": + "Imagina que eres parte de un equipo escolar encargado de organizar un evento de fin de curso. Cada uno de ustedes tiene un rol específico en la planificación. Discutan y tomen decisiones sobre las tareas futuras utilizando will, going to y present continuous.\n\n1. El Coordinador: Inicia la conversación y propone ideas generales para el evento.\n2. El Organizador: Responde a las propuestas y sugiere planes concretos.\n3. El Responsable de Logística: Considera los detalles prácticos y confirma las decisiones finales.\n\nUsen frases como:\n- \"I will contact the catering service.\" (para decisiones espontáneas)\n- \"We're going to have a dance performance.\" (para planes ya decididos)\n- \"The band is performing at 8 PM.\" (para arreglos ya establecidos)\n\nAsegúrense de usar las tres formas gramaticales en su conversación. Tomen decisiones juntos y elaboren un plan claro para el evento.", + "vocab": [ + {"lemma": "organize", "pos": "VERB"}, + {"lemma": "plan", "pos": "VERB"}, + {"lemma": "decide", "pos": "VERB"}, + {"lemma": "schedule", "pos": "VERB"}, + {"lemma": "arrangement", "pos": "NOUN"}, + {"lemma": "task", "pos": "NOUN"}, + {"lemma": "responsibility", "pos": "NOUN"}, + {"lemma": "event", "pos": "NOUN"}, + {"lemma": "upcoming", "pos": "ADJ"}, + {"lemma": "future", "pos": "ADJ"}, + ], + "roles": { + "400920c8-be79-43b9-a2cf-816df2f33254": { + "name": "Coordinador", + "id": "400920c8-be79-43b9-a2cf-816df2f33254", + }, + "e1d983b6-c7a9-4f23-ad70-d5b4f5a0adc9": { + "name": "Organizador", + "id": "e1d983b6-c7a9-4f23-ad70-d5b4f5a0adc9", + }, + "b7265a04-0a4f-4ad5-afa8-1834b9febbe7": { + "name": "Responsable de Logística", + "id": "b7265a04-0a4f-4ad5-afa8-1834b9febbe7", + }, + }, + "req": { + "topic": "Planes futuros: will, going to y present continuous", + "mode": "Decision Making", + "objective": + "Puedo elegir y usar correctamente will, going to o present continuous para planificar tareas y proyectos.", + "media": "nan", + "activity_cefr_level": "B2", + "language_of_instructions": "es", + "target_language": "en", + "number_of_participants": 3, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + }, + { + "activity_id": "3pA7kpjr6uT4lXvbz0CqJgLTW4yxRP7NGKKy", + "title": "Cazadores de Tiempos Verbales", + "learning_objective": + "Puedo reconocer ejemplos de past continuous y past perfect en documentos de incidentes y explicar su uso.", + "instructions": + "En esta actividad de Scavenger Hunt, ustedes serán Cazadores de Tiempos Verbales. Cada uno tendrá un rol específico:\n\n1. El Investigador: Busca y comparte imágenes de documentos de incidentes (pueden ser simulados) que contengan ejemplos de past continuous y past perfect.\n\n2. El Analista: Identifica y explica el uso de past continuous y past perfect en las imágenes compartidas.\n\n3. El Verificador: Confirma si las explicaciones son correctas y proporciona ejemplos adicionales si es necesario.\n\nPasos:\n1. El Investigador comparte una imagen de un documento de incidente.\n2. El Analista identifica los ejemplos de past continuous y past perfect, explicando su uso.\n3. El Verificador confirma la exactitud y añade información si es necesario.\n4. Repitan el proceso con nuevas imágenes.\n\nEjemplos de frases en inglés que podrían aparecer:\n- \"The employee was working when the accident occurred.\" (Past Continuous)\n- \"By the time the supervisor arrived, the situation had already escalated.\" (Past Perfect)\n\nRecuerden, no cambien de roles durante la actividad. ¡Buena caza de tiempos verbales!", + "vocab": [ + {"lemma": "incident", "pos": "NOUN"}, + {"lemma": "report", "pos": "NOUN"}, + {"lemma": "occur", "pos": "VERB"}, + {"lemma": "investigate", "pos": "VERB"}, + {"lemma": "witness", "pos": "NOUN"}, + {"lemma": "statement", "pos": "NOUN"}, + {"lemma": "evidence", "pos": "NOUN"}, + {"lemma": "timeline", "pos": "NOUN"}, + {"lemma": "prior", "pos": "ADJ"}, + {"lemma": "subsequent", "pos": "ADJ"}, + ], + "roles": { + "12498837-97ff-4774-a800-ee1f0f74cfe8": { + "name": "Investigador", + "id": "12498837-97ff-4774-a800-ee1f0f74cfe8", + }, + "221a2fd6-9fca-413e-9ed8-d4fc7fa3dad4": { + "name": "Analista", + "id": "221a2fd6-9fca-413e-9ed8-d4fc7fa3dad4", + }, + "1a9964ff-14ae-4c72-a97b-f10bd0477014": { + "name": "Verificador", + "id": "1a9964ff-14ae-4c72-a97b-f10bd0477014", + }, + }, + "req": { + "topic": + "Identificación de past continuous y past perfect en reportes", + "mode": "Scavenger Hunt", + "objective": + "Puedo reconocer ejemplos de past continuous y past perfect en documentos de incidentes y explicar su uso.", + "media": "images", + "activity_cefr_level": "B2", + "language_of_instructions": "es", + "target_language": "en", + "number_of_participants": 3, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + }, + { + "activity_id": "ZO0p4sDekDSO6tI4FvXeIBXG4DOkVzXmoXzl", + "title": "Adivina el Tiempo Verbal: Juego de 20 Preguntas", + "learning_objective": + "Puedo identificar y explicar distintos tiempos verbales mediante preguntas de sí/no.", + "instructions": + "1. El \"Adivinador\" piensa en una acción y un tiempo verbal específico (por ejemplo, \"Yo comí una manzana\" - Pretérito Simple).\n\n2. El \"Interrogador\" hace hasta 20 preguntas de sí/no para adivinar la acción y el tiempo verbal. Las preguntas deben estar relacionadas con el uso y contexto del tiempo verbal. Por ejemplo:\n - \"¿La acción ocurrió en el pasado?\"\n - \"¿Es una acción que se repite?\"\n - \"¿La acción tiene un punto final definido?\"\n\n3. El \"Adivinador\" solo puede responder \"Sí\" o \"No\".\n\n4. El \"Interrogador\" tiene que adivinar tanto la acción como el tiempo verbal correcto antes de las 20 preguntas.\n\n5. Después de adivinar o alcanzar las 20 preguntas, discutan por qué ese tiempo verbal es apropiado para la acción elegida.\n\nEjemplo en inglés:\nAdivinador: (piensa) \"I had been studying\" (Past Perfect Continuous)\nInterrogador: \"Did the action happen in the past?\"\nAdivinador: \"Yes\"\nInterrogador: \"Was it a continuous action?\"\nAdivinador: \"Yes\"\n...", + "vocab": [ + {"lemma": "tense", "pos": "NOUN"}, + {"lemma": "continuous", "pos": "ADJ"}, + {"lemma": "perfect", "pos": "ADJ"}, + {"lemma": "simple", "pos": "ADJ"}, + {"lemma": "past", "pos": "NOUN"}, + {"lemma": "present", "pos": "NOUN"}, + {"lemma": "future", "pos": "NOUN"}, + {"lemma": "action", "pos": "NOUN"}, + {"lemma": "completed", "pos": "ADJ"}, + {"lemma": "ongoing", "pos": "ADJ"}, + {"lemma": "regular", "pos": "ADJ"}, + {"lemma": "irregular", "pos": "ADJ"}, + {"lemma": "guess", "pos": "VERB"}, + {"lemma": "ask", "pos": "VERB"}, + {"lemma": "answer", "pos": "VERB"}, + ], + "roles": { + "6c29e127-d4f1-4d45-bcad-6e950b2c2dfc": { + "name": "Adivinador", + "id": "6c29e127-d4f1-4d45-bcad-6e950b2c2dfc", + }, + "c02592be-7414-4e99-805c-fd37ad534d25": { + "name": "Interrogador", + "id": "c02592be-7414-4e99-805c-fd37ad534d25", + }, + }, + "req": { + "topic": "Adivina el tiempo verbal", + "mode": "20-Question Game", + "objective": + "Puedo identificar y explicar distintos tiempos verbales mediante preguntas de sí/no.", + "media": "nan", + "activity_cefr_level": "B2", + "language_of_instructions": "es", + "target_language": "en", + "number_of_participants": 2, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + } + ], + }, + { + "title": "Informes a supervisores", + "description": + "Aquí aprenderás a dar status updates claros y concisos, redactar summaries de problemas y soluciones propuestas, usar passive voice en formal reports y emplear linking words y secuenciadores adecuados para presentar tu información de forma ordenada.", + "uuid": "b180c0e8-550c-4662-a162-7a7ac778a0cc", + "activities": [ + { + "activity_id": "yOZxWfSUBIYrh1Rg5XCd7JmQBN4ojVRCGHGW", + "title": "Actualización del Proyecto", + "learning_objective": + "Poder dar actualizaciones de estado claras y concisas utilizando expresiones temporales y conectores adecuados.", + "instructions": + "En esta actividad, uno de ustedes será el Líder del Proyecto y el otro será el Gerente. El Líder del Proyecto debe proporcionar una actualización concisa sobre el estado de un proyecto imaginario. El Gerente debe hacer preguntas de seguimiento para obtener más detalles.\n\nLíder del Proyecto: Prepara una breve actualización de estado que incluya:\n- Lo que se ha logrado hasta ahora\n- Lo que está en progreso\n- Los próximos pasos\n- Cualquier desafío o retraso\n\nUtiliza expresiones temporales como \"hasta ahora\", \"actualmente\", \"la próxima semana\", y conectores como \"además\", \"sin embargo\", \"por lo tanto\".\n\nGerente: Escucha atentamente y haz preguntas de seguimiento para obtener más detalles o aclaraciones.\n\nEjemplo de actualización:\n\"Hasta ahora, hemos completado la fase de diseño. Actualmente, estamos trabajando en el desarrollo del prototipo. La próxima semana, comenzaremos las pruebas iniciales. Sin embargo, nos enfrentamos a un retraso debido a problemas de suministro. Por lo tanto, es posible que necesitemos ajustar nuestro cronograma.\"\n\nRecuerden usar un lenguaje claro y conciso, y mantener la conversación fluida y natural.", + "vocab": [ + {"lemma": "update", "pos": "NOUN"}, + {"lemma": "progress", "pos": "NOUN"}, + {"lemma": "accomplish", "pos": "VERB"}, + {"lemma": "challenge", "pos": "NOUN"}, + {"lemma": "delay", "pos": "NOUN"}, + {"lemma": "currently", "pos": "ADV"}, + {"lemma": "next", "pos": "ADJ"}, + {"lemma": "however", "pos": "CONJ"}, + {"lemma": "therefore", "pos": "ADV"}, + {"lemma": "adjust", "pos": "VERB"}, + ], + "roles": { + "be1ef56a-5c21-45fa-af9b-4d8c0714f913": { + "name": "Líder del Proyecto", + "id": "be1ef56a-5c21-45fa-af9b-4d8c0714f913", + }, + "f7ae5a44-6eb3-4f15-be89-4374ba43c6a7": { + "name": "Gerente", + "id": "f7ae5a44-6eb3-4f15-be89-4374ba43c6a7", + }, + }, + "req": { + "topic": "Actualización de estado verbal (status update)", + "mode": "Roleplay", + "objective": + "Poder dar actualizaciones de estado claras y concisas utilizando expresiones temporales y conectores adecuados.", + "media": "nan", + "activity_cefr_level": "B2", + "language_of_instructions": "es", + "target_language": "en", + "number_of_participants": 2, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + }, + { + "activity_id": "ULPV3jxTKwOjzyZbTNJwmzU9MDQmLTf2oAHD", + "title": "Resolución de un Caso Empresarial", + "learning_objective": + "Ser capaz de discutir un caso real, seleccionar la mejor solución y presentar un resumen organizado.", + "instructions": + "Ustedes son un equipo de consultores que debe resolver un problema empresarial. Sigan estos pasos:\n\n1. El Analista de Datos presentará un problema empresarial real (por ejemplo, \"Our company's sales have decreased by 30% in the last quarter\").\n\n2. El Estratega propondrá 2-3 posibles soluciones (por ejemplo, \"We could launch a new marketing campaign\" o \"We should diversify our product line\").\n\n3. El Gerente de Proyectos evaluará cada solución, considerando pros y contras (use frases como \"On one hand... but on the other hand...\").\n\n4. Discutan juntos y lleguen a un consenso sobre la mejor solución.\n\n5. El Gerente de Proyectos presentará un resumen organizado de la decisión final y los pasos a seguir (use frases como \"In conclusion, we have decided to... Our next steps will be...\").\n\nRecuerden usar un lenguaje formal y profesional en inglés durante toda la actividad.", + "vocab": [ + {"lemma": "decrease", "pos": "VERB"}, + {"lemma": "launch", "pos": "VERB"}, + {"lemma": "diversify", "pos": "VERB"}, + {"lemma": "evaluate", "pos": "VERB"}, + {"lemma": "consensus", "pos": "NOUN"}, + {"lemma": "implement", "pos": "VERB"}, + {"lemma": "strategy", "pos": "NOUN"}, + {"lemma": "solution", "pos": "NOUN"}, + {"lemma": "pros and cons", "pos": "NOUN"}, + {"lemma": "summary", "pos": "NOUN"}, + ], + "roles": { + "14263e0a-00aa-4a1b-b0c0-8284b91e711b": { + "name": "Analista de Datos", + "id": "14263e0a-00aa-4a1b-b0c0-8284b91e711b", + }, + "f218180e-396c-4122-aca6-d1ed76802f0f": { + "name": "Estratega", + "id": "f218180e-396c-4122-aca6-d1ed76802f0f", + }, + "ee879900-24fb-4de4-90a0-74229a4bb092": { + "name": "Gerente de Proyectos", + "id": "ee879900-24fb-4de4-90a0-74229a4bb092", + }, + }, + "req": { + "topic": "Resumen de problemas y soluciones propuestas", + "mode": "Decision Making", + "objective": + "Ser capaz de discutir un caso real, seleccionar la mejor solución y presentar un resumen organizado.", + "media": "nan", + "activity_cefr_level": "B2", + "language_of_instructions": "es", + "target_language": "en", + "number_of_participants": 3, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + }, + { + "activity_id": "1HhuI26vnmwFkjN5UCVkU9BVzoeHoewM4EX7", + "title": "Práctica de informe formal en voz pasiva", + "learning_objective": + "Usar la voz pasiva para describir tareas completadas y eventos en un informe formal.", + "instructions": + "1. Tú eres Autor del informe. Prepara en tu mente un breve informe de 3–4 oraciones sobre un proyecto completado.\n2. Envía un voice_message en voz pasiva (por ejemplo: “The project was completed last Friday.” o “All tasks were reviewed and approved.”).\n3. Tú eres Revisor del informe. Escucha el voice_message y responde con otro voice_message usando la voz pasiva para hacer preguntas o comentarios formales (por ejemplo: “When was the document submitted?” o “Were any changes requested?”).", + "vocab": [ + {"lemma": "complete", "pos": "VERB"}, + {"lemma": "assign", "pos": "VERB"}, + {"lemma": "submit", "pos": "VERB"}, + {"lemma": "review", "pos": "VERB"}, + {"lemma": "report", "pos": "NOUN"}, + {"lemma": "document", "pos": "NOUN"}, + ], + "roles": { + "28630795-0a79-41d8-a6b0-d08449b61b7f": { + "name": "Autor del informe", + "id": "28630795-0a79-41d8-a6b0-d08449b61b7f", + }, + "f0a564be-014b-4b70-8c61-7f8fb93c2ea1": { + "name": "Revisor del informe", + "id": "f0a564be-014b-4b70-8c61-7f8fb93c2ea1", + }, + }, + "req": { + "topic": "Práctica de voz pasiva en informes", + "mode": "Conversation", + "objective": + "Usar la voz pasiva para describir tareas completadas y eventos en un informe formal.", + "media": "voice_messages", + "activity_cefr_level": "B2", + "language_of_instructions": "es", + "target_language": "en", + "number_of_participants": 2, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + }, + { + "activity_id": "7sRD60A2WyeIfKmIw4ItqxRU4iYSeaWy4nIr", + "title": "Búsqueda del Tesoro de Conectores", + "learning_objective": + "Identificar y clasificar palabras de secuenciación y enlace en ejemplos de informes formales.", + "instructions": + "1. Cada participante recibirá un rol específico.\n\n2. El Buscador de Secuencias enviará una imagen de un informe formal en inglés.\n\n3. El Cazador de Enlaces identificará y listará los conectores encontrados en la imagen.\n\n4. El Clasificador de Palabras categorizará los conectores listados (por ejemplo: secuencia, adición, contraste).\n\n5. Repitan el proceso con 3 imágenes diferentes.\n\n6. Al final, discutan cómo estos conectores mejoran la estructura y claridad del informe.\n\nEjemplo de conector de secuencia: \"First of all,\"\nEjemplo de conector de adición: \"Furthermore,\"\nEjemplo de conector de contraste: \"However,\"", + "vocab": [ + {"lemma": "furthermore", "pos": "ADV"}, + {"lemma": "nevertheless", "pos": "ADV"}, + {"lemma": "consequently", "pos": "ADV"}, + {"lemma": "in addition", "pos": "ADV"}, + {"lemma": "moreover", "pos": "ADV"}, + {"lemma": "therefore", "pos": "ADV"}, + {"lemma": "however", "pos": "ADV"}, + {"lemma": "subsequently", "pos": "ADV"}, + {"lemma": "in conclusion", "pos": "ADV"}, + {"lemma": "firstly", "pos": "ADV"}, + ], + "roles": { + "9b7f9b4b-eed6-43d7-b0a1-e5db7c9d9ea9": { + "name": "Buscador de Secuencias", + "id": "9b7f9b4b-eed6-43d7-b0a1-e5db7c9d9ea9", + }, + "bc2eabbf-402e-49ba-aa03-777032ba0130": { + "name": "Cazador de Enlaces", + "id": "bc2eabbf-402e-49ba-aa03-777032ba0130", + }, + "38ae00fa-4822-4971-aa8a-4cae00660b3f": { + "name": "Clasificador de Palabras", + "id": "38ae00fa-4822-4971-aa8a-4cae00660b3f", + }, + }, + "req": { + "topic": "Identificación de conectores en reportes", + "mode": "Scavenger Hunt", + "objective": + "Identificar y clasificar palabras de secuenciación y enlace en ejemplos de informes formales.", + "media": "images", + "activity_cefr_level": "B2", + "language_of_instructions": "es", + "target_language": "en", + "number_of_participants": 3, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + }, + { + "activity_id": "wizGTOXmRMTLUCYY4lZdiysFjMAo1cepNo5d", + "title": "Debate sobre la Estructura Ideal de Informes", + "learning_objective": + "Defender y argumentar la mejor estructura y orden para un informe dirigido a supervisores.", + "instructions": + "1. Cada participante recibirá un rol específico con una perspectiva única sobre la estructura de informes.\n\n2. Prepárate para defender tu posición utilizando frases como:\n - \"In my opinion, the most effective structure is...\"\n - \"I strongly believe that... should come first because...\"\n - \"From my perspective, it's crucial to...\"\n\n3. Durante el debate, presenta tus argumentos y responde a los de los demás.\n Usa expresiones como:\n - \"I see your point, however...\"\n - \"While I agree with... I think...\"\n - \"That's an interesting perspective, but have you considered...\"\n\n4. Al final, intenten llegar a un consenso sobre la estructura ideal de un informe.\n Utiliza frases como:\n - \"Perhaps we could compromise by...\"\n - \"Let's combine our ideas and...\"\n - \"I think we all agree that... is essential.\"\n\nRecuerda: Mantén un tono profesional y respetuoso en todo momento.", + "vocab": [ + {"lemma": "structure", "pos": "NOUN"}, + {"lemma": "report", "pos": "NOUN"}, + {"lemma": "argue", "pos": "VERB"}, + {"lemma": "defend", "pos": "VERB"}, + {"lemma": "perspective", "pos": "NOUN"}, + {"lemma": "compromise", "pos": "VERB"}, + {"lemma": "coherence", "pos": "NOUN"}, + {"lemma": "crucial", "pos": "ADJ"}, + {"lemma": "effective", "pos": "ADJ"}, + {"lemma": "consensus", "pos": "NOUN"}, + ], + "roles": { + "58d01a2d-5346-498e-8b7e-7135598ef1c4": { + "name": "Defensor de la Estructura Cronológica", + "id": "58d01a2d-5346-498e-8b7e-7135598ef1c4", + }, + "19d79012-5a4f-49d9-88c1-265d1fd28f51": { + "name": "Partidario de la Estructura Problema-Solución", + "id": "19d79012-5a4f-49d9-88c1-265d1fd28f51", + }, + "e229230c-e3dc-4ea6-b739-eedb7b5d753a": { + "name": "Abogado de la Estructura Temática", + "id": "e229230c-e3dc-4ea6-b739-eedb7b5d753a", + }, + "c0311b15-4cf0-42a8-8680-694f0f22a6e5": { + "name": "Promotor de la Estructura de Importancia", + "id": "c0311b15-4cf0-42a8-8680-694f0f22a6e5", + }, + }, + "req": { + "topic": "Estructura y coherencia de informes", + "mode": "Debate", + "objective": + "Defender y argumentar la mejor estructura y orden para un informe dirigido a supervisores.", + "media": "nan", + "activity_cefr_level": "B2", + "language_of_instructions": "es", + "target_language": "en", + "number_of_participants": 4, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + } + ], + }, + { + "title": "Planificación y previsión - Actividades comunicativas", + "description": + "Actividades para practicar el lenguaje de setting goals y deadlines, discutir KPIs y performance metrics, y negociar timelines con stakeholders de forma profesional y efectiva.", + "uuid": "fdecc21f-dc98-45f3-93f0-45a0a83ad7a1", + "activities": [ + { + "activity_id": "z1VFhxkN3gCBilcvIqTPZhScb2PW7n6tJdGg", + "title": + "Roleplay: Establecimiento de objetivos SMART y deadlines", + "learning_objective": + "Puedo establecer objetivos SMART y asignar deadlines claras en una conversación profesional.", + "instructions": + "1. Tú eres Gerente y tu compañero es Empleado.\n2. El Gerente propone un objetivo SMART para un proyecto (por ejemplo: “Increase customer satisfaction by 10% in Q3”).\n3. El Empleado pregunta detalles y confirma el propósito usando frases como “So the goal is…?” o “Can you specify…?”.\n4. Ambos acuerdan una deadline clara: “We will complete this by August 31st.”\n5. Practiquen asignar un milestone intermedio: “Let’s set a review on July 15th.”\n6. Finalicen la conversación resumiendo el objetivo SMART y la fecha límite: “To recap, we aim to… by…”.", + "vocab": [ + {"lemma": "goal", "pos": "NOUN"}, + {"lemma": "deadline", "pos": "NOUN"}, + {"lemma": "specify", "pos": "VERB"}, + {"lemma": "timeline", "pos": "NOUN"}, + {"lemma": "achieve", "pos": "VERB"}, + {"lemma": "milestone", "pos": "NOUN"}, + {"lemma": "adjust", "pos": "VERB"}, + {"lemma": "feedback", "pos": "NOUN"}, + ], + "roles": { + "7d0bdceb-eba2-4955-9eb7-bcf2e69abc5c": { + "name": "Gerente", + "id": "7d0bdceb-eba2-4955-9eb7-bcf2e69abc5c", + }, + "2415b2a3-191e-41d9-9e75-9c713fe875d9": { + "name": "Empleado", + "id": "2415b2a3-191e-41d9-9e75-9c713fe875d9", + }, + }, + "req": { + "topic": "Definición de objetivos y deadlines", + "mode": "Roleplay", + "objective": + "Puedo establecer objetivos SMART y asignar deadlines claras en una conversación profesional.", + "media": "nan", + "activity_cefr_level": "B2", + "language_of_instructions": "es", + "target_language": "en", + "number_of_participants": 2, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + }, + { + "activity_id": "TGNBt4JOFCj86K3E3y46rk2DFQ3K8cuX5No2", + "title": "Negociación de Plazos en el Proyecto de Innovación", + "learning_objective": + "Puedo negociar y acordar timelines realistas con diferentes partes interesadas usando lenguaje persuasivo.", + "instructions": + "En esta actividad, cada uno de ustedes asumirá un rol diferente en un proyecto de innovación. Utilizarán mensajes de voz para negociar y acordar plazos realistas para diferentes etapas del proyecto.\n\n1. Gerente de Proyecto: Inicia la conversación presentando el proyecto y sugiriendo plazos iniciales para cada etapa.\n2. Desarrollador Principal: Responde con preocupaciones sobre los plazos técnicos y sugiere ajustes.\n3. Representante del Cliente: Expresa expectativas sobre la entrega y negocia compromisos.\n\nUsen lenguaje persuasivo y frases como:\n- \"I understand your concerns, however...\"\n- \"What if we compromise on...\"\n- \"From my perspective, a realistic timeline would be...\"\n- \"Could we consider extending the deadline for...\"\n\nNegocien hasta llegar a un acuerdo sobre los plazos que satisfaga a todas las partes. Envíen al menos 3 mensajes de voz cada uno.", + "vocab": [ + {"lemma": "deadline", "pos": "NOUN"}, + {"lemma": "negotiate", "pos": "VERB"}, + {"lemma": "compromise", "pos": "NOUN"}, + {"lemma": "stakeholder", "pos": "NOUN"}, + {"lemma": "timeline", "pos": "NOUN"}, + {"lemma": "realistic", "pos": "ADJ"}, + {"lemma": "persuasive", "pos": "ADJ"}, + {"lemma": "extend", "pos": "VERB"}, + {"lemma": "concern", "pos": "NOUN"}, + {"lemma": "perspective", "pos": "NOUN"}, + ], + "roles": { + "eb8a5493-d2c5-4257-97c3-52dbbf856bb2": { + "name": "Gerente de Proyecto", + "id": "eb8a5493-d2c5-4257-97c3-52dbbf856bb2", + }, + "dd6120cf-e192-4cde-83b1-0453687c0615": { + "name": "Desarrollador Principal", + "id": "dd6120cf-e192-4cde-83b1-0453687c0615", + }, + "e9149d6a-7da4-48d1-a011-8dd805eea126": { + "name": "Representante del Cliente", + "id": "e9149d6a-7da4-48d1-a011-8dd805eea126", + }, + }, + "req": { + "topic": "Negociación de plazos con stakeholders", + "mode": "Decision Making", + "objective": + "Puedo negociar y acordar timelines realistas con diferentes partes interesadas usando lenguaje persuasivo.", + "media": "voice_messages", + "activity_cefr_level": "B2", + "language_of_instructions": "es", + "target_language": "en", + "number_of_participants": 3, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + }, + { + "activity_id": "A28vGZAhJpoy2BlX4HJLZRfhmBbe1O5ApqS0", + "title": "Debate de KPIs: Priorización en Proyectos", + "learning_objective": + "Puedo debatir la importancia de distintos KPIs y justificar su prioridad en la planificación de proyectos.", + "instructions": + "1. Cada participante recibirá una imagen de un KPI específico.\n\n2. Estudia tu KPI y prepara argumentos sobre por qué es crucial para la planificación de proyectos.\n\n3. En el debate, presenta tu KPI y argumenta su importancia. Usa frases como:\n \"I believe [KPI] is crucial because...\"\n \"This metric directly impacts...\"\n \"Without focusing on [KPI], we risk...\"\n\n4. Escucha los argumentos de los demás y prepara contraargumentos. Puedes usar:\n \"While I agree that [KPI] is important, I think...\"\n \"Have you considered the drawbacks of prioritizing [KPI]?\"\n\n5. Al final, vota por el KPI que crees que debería tener la máxima prioridad en la planificación de proyectos, excluyendo el tuyo.\n\n6. Justifica tu voto final usando frases como:\n \"I voted for [KPI] because...\"\n \"In the context of project planning, I believe [KPI] is most critical due to...\"\n\nRecuerda usar vocabulario específico de KPIs y métricas de rendimiento en inglés durante el debate.", + "vocab": [ + {"lemma": "key performance indicator", "pos": "NOUN"}, + {"lemma": "metric", "pos": "NOUN"}, + {"lemma": "prioritize", "pos": "VERB"}, + {"lemma": "crucial", "pos": "ADJ"}, + {"lemma": "impact", "pos": "VERB"}, + {"lemma": "efficiency", "pos": "NOUN"}, + {"lemma": "benchmark", "pos": "NOUN"}, + {"lemma": "optimize", "pos": "VERB"}, + {"lemma": "performance", "pos": "NOUN"}, + {"lemma": "justify", "pos": "VERB"}, + ], + "roles": { + "904d6af6-904b-4a4d-b182-7b5e54236f79": { + "name": "Defensor del ROI", + "id": "904d6af6-904b-4a4d-b182-7b5e54236f79", + }, + "48c2c5ae-30c6-4f18-8082-bbcad1d2610d": { + "name": "Promotor de la Satisfacción del Cliente", + "id": "48c2c5ae-30c6-4f18-8082-bbcad1d2610d", + }, + "f4415f39-360c-4bd6-9b0d-a0ecbaa2281c": { + "name": "Experto en Eficiencia Operativa", + "id": "f4415f39-360c-4bd6-9b0d-a0ecbaa2281c", + }, + "12bc06df-f768-4ca4-9c0b-4648f23028d6": { + "name": "Analista de Calidad del Producto", + "id": "12bc06df-f768-4ca4-9c0b-4648f23028d6", + }, + }, + "req": { + "topic": "Análisis de KPIs y métricas de rendimiento", + "mode": "Debate", + "objective": + "Puedo debatir la importancia de distintos KPIs y justificar su prioridad en la planificación de proyectos.", + "media": "images", + "activity_cefr_level": "B2", + "language_of_instructions": "es", + "target_language": "en", + "number_of_participants": 4, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + }, + { + "activity_id": "51sDJWfwJJgv1VUr5uLyHc8B6fGhIkMADmCS", + "title": "Juego de 20 Preguntas: Descubriendo KPIs", + "learning_objective": + "Puedo formular preguntas dirigidas para adivinar indicadores clave de rendimiento en un caso práctico.", + "instructions": + "1. El \"Gerente\" recibirá un video privado con información sobre un KPI específico de una empresa ficticia.\n\n2. Los \"Analistas\" deben hacer preguntas de sí o no para adivinar el KPI. Pueden hacer hasta 20 preguntas en total.\n\n3. El \"Gerente\" solo puede responder \"sí\", \"no\", o \"esa información no es relevante\".\n\n4. Los \"Analistas\" deben colaborar para formular preguntas estratégicas en inglés. Por ejemplo:\n - \"Is this KPI related to financial performance?\"\n - \"Does this metric measure customer satisfaction?\"\n - \"Is this indicator used in the marketing department?\"\n\n5. Después de cada 5 preguntas, los \"Analistas\" deben discutir y enviar un video corto resumiendo lo que han aprendido y su estrategia para las siguientes preguntas.\n\n6. Si los \"Analistas\" adivinan el KPI antes de las 20 preguntas, ganan. Si no, el \"Gerente\" gana.\n\n7. Al final, todos los participantes deben enviar un video explicando qué estrategias de preguntas fueron más efectivas para identificar el KPI.", + "vocab": [ + {"lemma": "performance", "pos": "NOUN"}, + {"lemma": "indicator", "pos": "NOUN"}, + {"lemma": "metric", "pos": "NOUN"}, + {"lemma": "measure", "pos": "VERB"}, + {"lemma": "relevant", "pos": "ADJ"}, + {"lemma": "strategy", "pos": "NOUN"}, + {"lemma": "identify", "pos": "VERB"}, + {"lemma": "effective", "pos": "ADJ"}, + {"lemma": "analyze", "pos": "VERB"}, + {"lemma": "collaborate", "pos": "VERB"}, + ], + "roles": { + "16c8e04e-24a6-4509-ba71-5c8273c9608e": { + "name": "Gerente", + "id": "16c8e04e-24a6-4509-ba71-5c8273c9608e", + }, + "6735ae94-f4bb-4e51-b649-c54bec356a71": { + "name": "Analista", + "id": "6735ae94-f4bb-4e51-b649-c54bec356a71", + }, + "1dd3f2e7-4354-44b7-b118-9c14a883e011": { + "name": "Analista", + "id": "1dd3f2e7-4354-44b7-b118-9c14a883e011", + }, + }, + "req": { + "topic": "Identificación de KPIs ocultos", + "mode": "20-Question Game", + "objective": + "Puedo formular preguntas dirigidas para adivinar indicadores clave de rendimiento en un caso práctico.", + "media": "videos", + "activity_cefr_level": "B2", + "language_of_instructions": "es", + "target_language": "en", + "number_of_participants": 3, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + }, + { + "activity_id": "pFgDf4qdjcy6rmNEeWzIB15Nn3w7JRTyRP5N", + "title": "Caza del Tesoro de Planificación de Proyectos", + "learning_objective": + "Puedo localizar información sobre hitos y plazos en materiales de proyecto y presentarlos en orden cronológico.", + "instructions": + "En esta actividad, ustedes serán parte de un equipo de proyecto que busca información clave sobre hitos y plazos. Cada uno tendrá un rol específico:\n\n1. Gerente de Proyecto: Tu tarea es coordinar la búsqueda y asegurarte de que toda la información se recopile en orden cronológico.\n\n2. Investigador: Debes buscar y encontrar información sobre hitos y plazos en los materiales del proyecto.\n\n3. Cronologista: Tu trabajo es organizar la información encontrada en una línea de tiempo clara y coherente.\n\nInstrucciones:\n1. El Gerente de Proyecto iniciará la actividad diciendo: \"Let's begin our project timeline scavenger hunt.\"\n2. El Investigador buscará información en los materiales proporcionados y la compartirá, por ejemplo: \"I found a milestone: project kickoff meeting on March 1st.\"\n3. El Cronologista tomará esta información y la colocará en orden, diciendo algo como: \"I'm adding the project kickoff to our timeline as the first item.\"\n4. Continúen este proceso hasta que hayan encontrado y organizado al menos 5 hitos o plazos importantes.\n5. Al final, el Gerente de Proyecto pedirá al Cronologista que presente la línea de tiempo completa.\n\nRecuerden usar frases en inglés relacionadas con la planificación de proyectos, hitos y plazos.", + "vocab": [ + {"lemma": "milestone", "pos": "NOUN"}, + {"lemma": "deadline", "pos": "NOUN"}, + {"lemma": "timeline", "pos": "NOUN"}, + {"lemma": "schedule", "pos": "NOUN"}, + {"lemma": "project", "pos": "NOUN"}, + {"lemma": "plan", "pos": "VERB"}, + {"lemma": "organize", "pos": "VERB"}, + {"lemma": "coordinate", "pos": "VERB"}, + {"lemma": "chronological", "pos": "ADJ"}, + {"lemma": "sequential", "pos": "ADJ"}, + ], + "roles": { + "ea30c500-a978-4d5e-acb6-a9d7408f7985": { + "name": "Gerente de Proyecto", + "id": "ea30c500-a978-4d5e-acb6-a9d7408f7985", + }, + "d42f5010-892f-4a66-824a-ad50108efb4e": { + "name": "Investigador", + "id": "d42f5010-892f-4a66-824a-ad50108efb4e", + }, + "34cb2325-3d56-4b61-bf65-709928430ea9": { + "name": "Cronologista", + "id": "34cb2325-3d56-4b61-bf65-709928430ea9", + }, + }, + "req": { + "topic": "Escenario de planificación de proyecto", + "mode": "Scavenger Hunt", + "objective": + "Puedo localizar información sobre hitos y plazos en materiales de proyecto y presentarlos en orden cronológico.", + "media": "nan", + "activity_cefr_level": "B2", + "language_of_instructions": "es", + "target_language": "en", + "number_of_participants": 3, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + } + ], + }, + { + "title": "Solicitudes e instrucciones corteses", + "description": + "Aprenderás a formular polite requests usando modals (could, would, might), dar step-by-step instructions claras a tu equipo y usar lenguaje indirecto para no sonar demasiado directo con clients y suppliers.", + "uuid": "cb9ac299-0ccc-4681-871b-940c10e0506b", + "activities": [ + { + "activity_id": "7JhQv66GlTFyM5MqWMynHMaHDv7VSScQNcMi", + "title": "Solicitud de cambio de turno", + "learning_objective": + "Puedo formular solicitudes corteses usando could y would para pedir cambios de turno a un colega o supervisor.", + "instructions": + "1. Tú (Empleado) envía un voice_message de 1–2 minutos: saluda y formula tu solicitud usando could o would. Ejemplo: “Could you cover my shift on Friday, please?”\n2. Tú (Supervisor) respondes en un voice_message de 1–2 minutos: aceptas o propones un cambio alternativo usando would o could. Ejemplo: “I’m sorry, I can’t on Friday, would Saturday work?”\n3. Empleado responde en un tercer voice_message: confirma o sugiere otra opción y agradece. Ejemplo: “Saturday works for me, thanks so much!”\n4. Ambos: revisen sus mensajes y repitan si quieren practicar otras variantes.", + "vocab": [ + {"lemma": "could", "pos": "MODAL"}, + {"lemma": "would", "pos": "MODAL"}, + {"lemma": "shift", "pos": "NOUN"}, + {"lemma": "cover", "pos": "VERB"}, + {"lemma": "request", "pos": "NOUN"}, + {"lemma": "alternative", "pos": "NOUN"}, + {"lemma": "schedule", "pos": "NOUN"}, + ], + "roles": { + "6eecd1c1-52b1-4a39-b42f-903475acece9": { + "name": "Empleado", + "id": "6eecd1c1-52b1-4a39-b42f-903475acece9", + }, + "425e4838-61f1-42d0-8c71-13f6fc13f345": { + "name": "Supervisor", + "id": "425e4838-61f1-42d0-8c71-13f6fc13f345", + }, + }, + "req": { + "topic": "Solicitar cambio de turno usando modals", + "mode": "Conversation", + "objective": + "Puedo formular solicitudes corteses usando could y would para pedir cambios de turno a un colega o supervisor.", + "media": "voice_messages", + "activity_cefr_level": "B2", + "language_of_instructions": "es", + "target_language": "en", + "number_of_participants": 2, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + }, + { + "activity_id": "LpOGdf6x7fLJVLxaAJT8SfRtl9ZjGpjY2OPY", + "title": "Simulacro de Emergencia en la Oficina", + "learning_objective": + "Puedo dar instrucciones claras y detalladas paso a paso para un procedimiento de seguridad usando imperativos y lenguaje indirecto.", + "instructions": + "Ustedes van a simular una situación de emergencia en una oficina. \n\nSupervisor de Seguridad: Imagina que eres el supervisor de seguridad de una oficina. Tu tarea es dar instrucciones claras y detalladas al empleado sobre cómo evacuar el edificio en caso de incendio. Usa imperativos y lenguaje indirecto para explicar el procedimiento paso a paso. Por ejemplo: \"First, you should remain calm. Then, proceed to the nearest emergency exit.\"\n\nEmpleado: Eres un nuevo empleado en la oficina que necesita entender el procedimiento de evacuación. Escucha atentamente las instrucciones del supervisor de seguridad. Haz preguntas si algo no está claro, por ejemplo: \"Could you please clarify what to do if the nearest exit is blocked?\"\n\nRecuerden usar frases como \"It's important that...\", \"Make sure to...\", \"You must...\", \"Don't forget to...\" para dar instrucciones claras y enfatizar puntos importantes.", + "vocab": [ + {"lemma": "evacuate", "pos": "VERB"}, + {"lemma": "emergency", "pos": "NOUN"}, + {"lemma": "procedure", "pos": "NOUN"}, + {"lemma": "exit", "pos": "NOUN"}, + {"lemma": "safety", "pos": "NOUN"}, + {"lemma": "instruction", "pos": "NOUN"}, + {"lemma": "fire", "pos": "NOUN"}, + {"lemma": "alarm", "pos": "NOUN"}, + {"lemma": "extinguisher", "pos": "NOUN"}, + {"lemma": "assembly point", "pos": "NOUN"}, + ], + "roles": { + "cd7ae96a-aa3e-4ac8-83b6-6d1351020a6c": { + "name": "Supervisor de Seguridad", + "id": "cd7ae96a-aa3e-4ac8-83b6-6d1351020a6c", + }, + "e7696549-7f94-4f4d-bc1e-3b776575e2db": { + "name": "Empleado", + "id": "e7696549-7f94-4f4d-bc1e-3b776575e2db", + }, + }, + "req": { + "topic": "Dar instrucciones de seguridad paso a paso", + "mode": "Roleplay", + "objective": + "Puedo dar instrucciones claras y detalladas paso a paso para un procedimiento de seguridad usando imperativos y lenguaje indirecto.", + "media": "nan", + "activity_cefr_level": "B2", + "language_of_instructions": "es", + "target_language": "en", + "number_of_participants": 2, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + }, + { + "title": "Reformulación Cortés: Memo Interno", + "learning_objective": + "Podemos decidir colectivamente la mejor versión indirecta de instrucciones para mantener la cortesía en un memo interno.", + "instructions": + "1. Cada uno de ustedes recibirá un rol específico dentro de una empresa.\n\n2. Se les presentará una serie de instrucciones directas que necesitan ser reformuladas de manera indirecta y cortés para un memo interno.\n\n3. Cada uno debe proponer una versión indirecta y cortés de la instrucción dada, considerando su rol en la empresa.\n\n4. Después de que cada uno haya propuesto su versión, discutan las opciones y decidan colectivamente cuál es la mejor formulación indirecta y cortés.\n\n5. Repitan este proceso para cada instrucción directa presentada.\n\nEjemplo:\nInstrucción directa: \"Entreguen los informes mañana sin falta.\"\nVersión indirecta y cortés: \"Nos sería de gran ayuda si pudieran entregar los informes para mañana, por favor.\"\n\nRecuerden utilizar estructuras como:\n- \"Would it be possible to...\"\n- \"We would appreciate if...\"\n- \"It would be helpful if...\"\n- \"Could you please consider...\"\n- \"Might I suggest...\"", + "vocab": [ + {"lemma": "polite", "pos": "ADJ"}, + {"lemma": "indirect", "pos": "ADJ"}, + {"lemma": "request", "pos": "NOUN"}, + {"lemma": "memo", "pos": "NOUN"}, + {"lemma": "rephrase", "pos": "VERB"}, + {"lemma": "suggest", "pos": "VERB"}, + {"lemma": "appreciate", "pos": "VERB"}, + {"lemma": "consider", "pos": "VERB"}, + {"lemma": "collectively", "pos": "ADV"}, + {"lemma": "courteous", "pos": "ADJ"}, + ], + "roles": { + "c673d408-5131-4568-9a3c-92722b0e9e6c": { + "name": "Gerente de Proyecto", + "id": "c673d408-5131-4568-9a3c-92722b0e9e6c", + }, + "9638af56-3835-4926-9d64-43a982e4c519": { + "name": "Asistente Administrativo", + "id": "9638af56-3835-4926-9d64-43a982e4c519", + }, + "c0ccec5e-0805-4d82-b75d-b68644955700": { + "name": "Especialista en Recursos Humanos", + "id": "c0ccec5e-0805-4d82-b75d-b68644955700", + }, + }, + "req": { + "topic": + "Reformular instrucciones directas en lenguaje indirecto", + "mode": "Decision Making", + "objective": + "Podemos decidir colectivamente la mejor versión indirecta de instrucciones para mantener la cortesía en un memo interno.", + "media": "nan", + "activity_cefr_level": "B2", + "language_of_instructions": "es", + "target_language": "en", + "number_of_participants": 3, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "8mZ8ps4gkVPIzRgPxIRmJW0MlPm3nKMOqOGm", + }, + { + "title": "Búsqueda del tesoro de cortesía en el almacén", + "learning_objective": + "Podemos encontrar y clasificar expresiones corteses en imágenes de manuales de almacén y explicar su uso.", + "instructions": + "1. Cada participante recibirá un rol específico en el almacén.\n\n2. Busquen imágenes de manuales de almacén que contengan expresiones corteses relacionadas con su rol.\n\n3. Cuando encuentren una imagen adecuada, compártanla en el chat grupal.\n\n4. Para cada imagen, identifiquen y clasifiquen las expresiones corteses encontradas.\n\n5. Expliquen cómo se usa cada expresión en el contexto del almacén.\n\n6. Discutan en grupo si la expresión es formal o informal, y en qué situaciones específicas se utilizaría.\n\nEjemplo de expresión cortés: \"Could you please check the inventory?\"\nClasificación: Petición formal\nUso: Se utiliza para solicitar amablemente a un colega que verifique el inventario.\n\n¡Buena suerte en su búsqueda del tesoro de cortesía!", + "vocab": [ + {"lemma": "polite", "pos": "ADJ"}, + {"lemma": "request", "pos": "NOUN"}, + {"lemma": "kindly", "pos": "ADV"}, + {"lemma": "please", "pos": "INTJ"}, + {"lemma": "thank you", "pos": "INTJ"}, + {"lemma": "appreciate", "pos": "VERB"}, + {"lemma": "would you mind", "pos": "PHRASE"}, + {"lemma": "could you", "pos": "PHRASE"}, + {"lemma": "warehouse", "pos": "NOUN"}, + {"lemma": "inventory", "pos": "NOUN"}, + ], + "roles": { + "75cbf753-5fe4-4be1-8878-554975254527": { + "name": "Supervisor de almacén", + "id": "75cbf753-5fe4-4be1-8878-554975254527", + }, + "f8e0fd86-1767-42a6-a336-a31c92162553": { + "name": "Operario de montacargas", + "id": "f8e0fd86-1767-42a6-a336-a31c92162553", + }, + "c5b0812e-2ed9-4ec6-8ef8-c4724a6ebcf1": { + "name": "Encargado de inventario", + "id": "c5b0812e-2ed9-4ec6-8ef8-c4724a6ebcf1", + }, + }, + "req": { + "topic": "Identificar lenguaje cortés en manuales", + "mode": "Scavenger Hunt", + "objective": + "Podemos encontrar y clasificar expresiones corteses en imágenes de manuales de almacén y explicar su uso.", + "media": "images", + "activity_cefr_level": "B2", + "language_of_instructions": "es", + "target_language": "en", + "number_of_participants": 3, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "7KfHzg6A5sZOtMaryXNu4tnJh503XFYemoKV", + }, + { + "title": "Adivina la Tarea del Almacén", + "learning_objective": + "Puedo usar preguntas indirectas y modals para averiguar qué tarea de almacén ha pensado mi compañero.", + "instructions": + "En este juego de 20 preguntas, uno de ustedes será el Empleado del Almacén y el otro será el Supervisor Curioso. \n\nEmpleado del Almacén: Piensa en una tarea específica que se realiza en un almacén (por ejemplo, cargar cajas, hacer inventario, operar una carretilla elevadora, etc.). No reveles esta tarea.\n\nSupervisor Curioso: Tu objetivo es adivinar la tarea que el Empleado del Almacén ha pensado. Haz preguntas indirectas utilizando modals para obtener información. Por ejemplo:\n- \"Could you tell me if the task involves heavy lifting?\"\n- \"I wonder if you could explain whether this task is done daily?\"\n- \"Would you mind sharing if special equipment is needed for this task?\"\n\nEmpleado del Almacén: Responde las preguntas con \"sí\", \"no\", o \"no estoy seguro\". Proporciona explicaciones breves si es necesario.\n\nEl juego termina cuando el Supervisor Curioso adivina correctamente la tarea o después de 20 preguntas. ¡Buena suerte!", + "vocab": [ + {"lemma": "warehouse", "pos": "NOUN"}, + {"lemma": "task", "pos": "NOUN"}, + {"lemma": "indirect question", "pos": "NOUN"}, + {"lemma": "modal verb", "pos": "NOUN"}, + {"lemma": "guess", "pos": "VERB"}, + {"lemma": "inquire", "pos": "VERB"}, + {"lemma": "curious", "pos": "ADJ"}, + {"lemma": "specific", "pos": "ADJ"}, + ], + "roles": { + "797d9300-ad59-468a-a81e-5c3800241897": { + "name": "Empleado del Almacén", + "id": "797d9300-ad59-468a-a81e-5c3800241897", + }, + "6a669f8e-92fb-42a9-a5c3-ce21aa93d464": { + "name": "Supervisor Curioso", + "id": "6a669f8e-92fb-42a9-a5c3-ce21aa93d464", + }, + }, + "req": { + "topic": "Adivina la tarea con preguntas indirectas", + "mode": "20-Question Game", + "objective": + "Puedo usar preguntas indirectas y modals para averiguar qué tarea de almacén ha pensado mi compañero.", + "media": "nan", + "activity_cefr_level": "B2", + "language_of_instructions": "es", + "target_language": "en", + "number_of_participants": 2, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "KigGVHnA0GZUFYJm9l95gkgFPocPDmAAmalH", + } + ], + }, + { + "title": "Módulo 5: Condicionales", + "description": + "Repasaremos el zero conditional para rules and procedures, el first conditional para relaciones causa-efecto y el second conditional para situaciones hipotéticas, con ejemplos del entorno de almacén.", + "uuid": "3800441f-d142-4104-aa09-f1698343924e", + "activities": [ + { + "title": "Reglas del Almacén", + "learning_objective": + "Puedo usar el zero conditional para establecer reglas y procedimientos estándar en el almacén.", + "instructions": + "En esta actividad, uno de ustedes será el Gerente del Almacén y el otro será el Nuevo Empleado. El Gerente debe explicar las reglas y procedimientos del almacén utilizando el zero conditional en inglés. El Nuevo Empleado debe hacer preguntas para aclarar las reglas.\n\nGerente: Comienza explicando 5 reglas importantes del almacén usando la estructura \"If + present simple, present simple\". Por ejemplo: \"If you see a spill, you clean it up immediately.\"\n\nNuevo Empleado: Haz preguntas sobre las reglas para obtener más detalles o aclaraciones. Por ejemplo: \"What if the spill is too big to clean alone?\"\n\nContinúen la conversación, asegurándose de usar el zero conditional para todas las reglas y procedimientos. Traten de usar al menos 10 frases con zero conditional durante la actividad.", + "vocab": [ + {"lemma": "warehouse", "pos": "NOUN"}, + {"lemma": "safety", "pos": "NOUN"}, + {"lemma": "procedure", "pos": "NOUN"}, + {"lemma": "inventory", "pos": "NOUN"}, + {"lemma": "equipment", "pos": "NOUN"}, + {"lemma": "protocol", "pos": "NOUN"}, + {"lemma": "comply", "pos": "VERB"}, + {"lemma": "regulation", "pos": "NOUN"}, + {"lemma": "hazard", "pos": "NOUN"}, + {"lemma": "standard", "pos": "NOUN"}, + ], + "roles": { + "2bbb025b-0a8e-4ac8-ae6b-03febe1914e8": { + "name": "Gerente del Almacén", + "id": "2bbb025b-0a8e-4ac8-ae6b-03febe1914e8", + }, + "2bd8d177-87f9-49cc-9064-babcbb0debb6": { + "name": "Nuevo Empleado", + "id": "2bd8d177-87f9-49cc-9064-babcbb0debb6", + }, + }, + "req": { + "topic": "Zero conditional para reglas y procedimientos", + "mode": "Decision Making", + "objective": + "Puedo usar el zero conditional para establecer reglas y procedimientos estándar en el almacén.", + "media": "nan", + "activity_cefr_level": "B2", + "language_of_instructions": "es", + "target_language": "en", + "number_of_participants": 2, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "7Vd0g8Iftvz764Q5KKZ5fwTKAz2EBYMUgUwC", + }, + { + "title": "Advertencias en el Almacén", + "learning_objective": + "Puedo usar el first conditional para advertir sobre consecuencias de acciones en el almacén.", + "instructions": + "Imagina que trabajas en un almacén. Tu compañero es nuevo y necesita orientación. Usa el first conditional para advertirle sobre las posibles consecuencias de sus acciones en el almacén. Envía mensajes de voz para comunicarte.\n\nEjemplo:\n\"If you don't wear a safety helmet, you'll risk head injuries.\"\n\"If we stack the boxes too high, they might fall and cause accidents.\"\n\nSupervisor: Comienza dando la bienvenida al nuevo empleado y ofrece 3-4 advertencias usando el first conditional.\n\nEmpleado Nuevo: Responde a cada advertencia, agradeciendo la información y haciendo una pregunta adicional sobre seguridad o procedimientos.", + "vocab": [ + {"lemma": "warehouse", "pos": "NOUN"}, + {"lemma": "safety", "pos": "NOUN"}, + {"lemma": "consequence", "pos": "NOUN"}, + {"lemma": "hazard", "pos": "NOUN"}, + {"lemma": "caution", "pos": "NOUN"}, + {"lemma": "warn", "pos": "VERB"}, + {"lemma": "prevent", "pos": "VERB"}, + {"lemma": "comply", "pos": "VERB"}, + {"lemma": "potential", "pos": "ADJ"}, + {"lemma": "cautious", "pos": "ADJ"}, + ], + "roles": { + "a39a3841-fa12-4a84-a212-8c38a0601ff7": { + "name": "Supervisor", + "id": "a39a3841-fa12-4a84-a212-8c38a0601ff7", + }, + "2aa79c65-816a-4ffe-9c86-5b3cc958bbca": { + "name": "Empleado Nuevo", + "id": "2aa79c65-816a-4ffe-9c86-5b3cc958bbca", + }, + }, + "req": { + "topic": "First conditional para relaciones causa-efecto", + "mode": "Roleplay", + "objective": + "Puedo usar el first conditional para advertir sobre consecuencias de acciones en el almacén.", + "media": "voice_messages", + "activity_cefr_level": "B2", + "language_of_instructions": "es", + "target_language": "en", + "number_of_participants": 2, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "oYWl0VTym4OoxTjwKnGQ6rCzIVSWFmhTOmUP", + }, + { + "title": "Debate de Soluciones Hipotéticas", + "learning_objective": + "Puedo usar el second conditional para proponer soluciones hipotéticas a problemas logísticos.", + "instructions": + "1. Cada participante recibirá una imagen de un problema logístico.\n\n2. Observa tu imagen y describe el problema usando el second conditional. Por ejemplo: \"If this situation were to occur, it would cause...\"\n\n3. Propón una solución hipotética usando el second conditional. Por ejemplo: \"If we implemented this solution, it would solve the problem by...\"\n\n4. Debate con tu compañero sobre las ventajas y desventajas de cada solución propuesta. Usa frases como:\n - \"If we chose your solution, it might lead to...\"\n - \"That could work, but if we did that, we would need to consider...\"\n\n5. Al final, lleguen a un acuerdo sobre cuál sería la mejor solución hipotética para cada problema.", + "vocab": [ + {"lemma": "implement", "pos": "VERB"}, + {"lemma": "solution", "pos": "NOUN"}, + {"lemma": "logistics", "pos": "NOUN"}, + {"lemma": "hypothetical", "pos": "ADJ"}, + {"lemma": "advantage", "pos": "NOUN"}, + {"lemma": "disadvantage", "pos": "NOUN"}, + {"lemma": "consider", "pos": "VERB"}, + {"lemma": "agreement", "pos": "NOUN"}, + {"lemma": "debate", "pos": "VERB"}, + {"lemma": "propose", "pos": "VERB"}, + ], + "roles": { + "2aca6e76-e848-4475-89c0-8136f8db1f86": { + "name": "Participante", + "id": "2aca6e76-e848-4475-89c0-8136f8db1f86", + }, + "84bd90c1-0c7b-4f23-97da-121e90644644": { + "name": "Participante", + "id": "84bd90c1-0c7b-4f23-97da-121e90644644", + }, + }, + "req": { + "topic": "Second conditional para situaciones hipotéticas", + "mode": "Debate", + "objective": + "Puedo usar el second conditional para proponer soluciones hipotéticas a problemas logísticos.", + "media": "images", + "activity_cefr_level": "B2", + "language_of_instructions": "es", + "target_language": "en", + "number_of_participants": 2, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "pTIkQZ3fwppBC8rps8vPqxU6ptyXibfVOfgc", + }, + { + "title": "Juego de 20 Preguntas: Condicionales en el Almacén", + "learning_objective": + "Puedo identificar y corregir ejemplos de zero, first y second conditional en frases de almacén.", + "instructions": + "1. Un participante (el Empleado) pensará en un objeto o situación común en un almacén.\n\n2. El otro participante (el Cliente) hará hasta 20 preguntas para adivinar el objeto o situación. Estas preguntas deben usar condicionales (zero, first, o second).\n\n3. El Empleado responderá usando condicionales. Si la pregunta o respuesta no usa el condicional correctamente, el otro jugador debe corregirla.\n\nEjemplos de preguntas:\n- \"If I need to lift heavy boxes, what would you recommend?\"\n- \"If we run out of stock, what do we usually do?\"\n\nEjemplos de respuestas:\n- \"If you need to lift heavy boxes, I would recommend using a forklift.\"\n- \"If we run out of stock, we usually order more immediately.\"\n\n4. El juego termina cuando el Cliente adivina correctamente o se agotan las 20 preguntas.\n\n5. Discutan qué condicionales usaron y por qué.", + "vocab": [ + {"lemma": "warehouse", "pos": "NOUN"}, + {"lemma": "inventory", "pos": "NOUN"}, + {"lemma": "stock", "pos": "NOUN"}, + {"lemma": "forklift", "pos": "NOUN"}, + {"lemma": "shelf", "pos": "NOUN"}, + {"lemma": "order", "pos": "VERB"}, + {"lemma": "store", "pos": "VERB"}, + {"lemma": "deliver", "pos": "VERB"}, + {"lemma": "if", "pos": "CONJ"}, + {"lemma": "would", "pos": "AUX"}, + {"lemma": "might", "pos": "AUX"}, + {"lemma": "could", "pos": "AUX"}, + ], + "roles": { + "da58f7b6-39a1-472a-b4f7-dc981ee7eca6": { + "name": "Empleado", + "id": "da58f7b6-39a1-472a-b4f7-dc981ee7eca6", + }, + "d7ef0e98-d482-435b-b46d-7bb51cb6917a": { + "name": "Cliente", + "id": "d7ef0e98-d482-435b-b46d-7bb51cb6917a", + }, + }, + "req": { + "topic": "Uso mixto de condicionales en contexto de almacén", + "mode": "20-Question Game", + "objective": + "Puedo identificar y corregir ejemplos de zero, first y second conditional en frases de almacén.", + "media": "nan", + "activity_cefr_level": "B2", + "language_of_instructions": "es", + "target_language": "en", + "number_of_participants": 2, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "S6SAZA5cisXmxJtm27zIC0eiBoQDsE6o5nBs", + }, + { + "title": "Cazadores de Condicionales en el Almacén", + "learning_objective": + "Puedo encontrar e interpretar ejemplos reales de condicionales en señalizaciones y manuales de almacén.", + "instructions": + "En esta actividad de \"Scavenger Hunt\", ustedes serán cazadores de condicionales en un almacén virtual. Cada uno tendrá un rol específico:\n\n1. El Supervisor de Seguridad buscará condicionales en señales de seguridad.\n2. El Operador de Maquinaria se enfocará en condicionales en manuales de equipos.\n3. El Gestor de Inventario encontrará condicionales en procedimientos de almacenamiento.\n\nInstrucciones:\n1. Cada uno explorará su área asignada en el almacén virtual.\n2. Encuentren y compartan 3 ejemplos de frases condicionales en español relacionadas con su rol.\n3. Traduzcan cada frase al inglés, identificando el tipo de condicional (zero, first, second, or third).\n4. Discutan cómo cada condicional se aplica en el contexto del almacén.\n\nEjemplo:\nEspañol: \"Si ves un derrame, limpia inmediatamente.\"\nInglés: \"If you see a spill, clean it up immediately.\" (Zero conditional)\n\n¡Buena caza de condicionales!", + "vocab": [ + {"lemma": "warehouse", "pos": "NOUN"}, + {"lemma": "safety", "pos": "NOUN"}, + {"lemma": "machinery", "pos": "NOUN"}, + {"lemma": "inventory", "pos": "NOUN"}, + {"lemma": "conditional", "pos": "NOUN"}, + {"lemma": "signage", "pos": "NOUN"}, + {"lemma": "manual", "pos": "NOUN"}, + {"lemma": "procedure", "pos": "NOUN"}, + {"lemma": "interpret", "pos": "VERB"}, + {"lemma": "identify", "pos": "VERB"}, + ], + "roles": { + "8f9e65d0-ad93-433a-a246-5d7de25135da": { + "name": "Supervisor de Seguridad", + "id": "8f9e65d0-ad93-433a-a246-5d7de25135da", + }, + "94245213-b559-4113-b376-d82da57b2855": { + "name": "Operador de Maquinaria", + "id": "94245213-b559-4113-b376-d82da57b2855", + }, + "96416ada-5614-4087-a098-ca11c9444b27": { + "name": "Gestor de Inventario", + "id": "96416ada-5614-4087-a098-ca11c9444b27", + }, + }, + "req": { + "topic": "Identificación de condicionales en el almacén", + "mode": "Scavenger Hunt", + "objective": + "Puedo encontrar e interpretar ejemplos reales de condicionales en señalizaciones y manuales de almacén.", + "media": "nan", + "activity_cefr_level": "B2", + "language_of_instructions": "es", + "target_language": "en", + "number_of_participants": 3, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "pogYJy875vMjSGmqj0VafBQGcVXg6HRUvOD3", + } + ], + }, + { + "title": "Vocabulario de almacén y logística", + "description": + "Ampliarás tu léxico sobre equipment, inventory y shipment terminology, vocabulario de safety and hazards, así como expresiones de units of measurement y quantity.", + "uuid": "55213743-faa9-492b-923b-98f321819e56", + "activities": [ + { + "title": "El Juego de las 20 Preguntas: Equipos de Almacén", + "learning_objective": + "Al finalizar, podrás identificar y describir diferentes tipos de equipos de almacén mediante preguntas y respuestas.", + "instructions": + "Instrucciones:\n\n1. Un participante (el Adivinador) pensará en un equipo de almacén sin revelarlo.\n2. El otro participante (el Interrogador) hará hasta 20 preguntas de sí/no para adivinar el equipo.\n3. El Adivinador solo puede responder \"sí\" o \"no\".\n4. El Interrogador intentará adivinar el equipo antes de las 20 preguntas.\n\nEjemplos de preguntas en inglés:\n- \"Is it used for lifting heavy objects?\"\n- \"Does it have wheels?\"\n- \"Is it operated manually or electrically?\"\n\nRecuerda usar vocabulario específico de equipos de almacén en tus preguntas y respuestas.", + "vocab": [ + {"lemma": "forklift", "pos": "NOUN"}, + {"lemma": "pallet jack", "pos": "NOUN"}, + {"lemma": "conveyor belt", "pos": "NOUN"}, + {"lemma": "shelving unit", "pos": "NOUN"}, + {"lemma": "lift", "pos": "VERB"}, + {"lemma": "store", "pos": "VERB"}, + {"lemma": "transport", "pos": "VERB"}, + {"lemma": "heavy-duty", "pos": "ADJ"}, + {"lemma": "electric", "pos": "ADJ"}, + {"lemma": "manual", "pos": "ADJ"}, + ], + "roles": { + "524abd72-de60-487d-9eef-d08932c15f8b": { + "name": "Adivinador", + "id": "524abd72-de60-487d-9eef-d08932c15f8b", + }, + "7b2576ae-d6dd-4534-a5a6-1d9ad831b694": { + "name": "Interrogador", + "id": "7b2576ae-d6dd-4534-a5a6-1d9ad831b694", + }, + }, + "req": { + "topic": "Terminología de equipos de almacén", + "mode": "20-Question Game", + "objective": + "Al finalizar, podrás identificar y describir diferentes tipos de equipos de almacén mediante preguntas y respuestas.", + "media": "nan", + "activity_cefr_level": "B2", + "language_of_instructions": "es", + "target_language": "en", + "number_of_participants": 2, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "Ef9Nd5HaPiKBijiasbqSXtF7Js0JvlhiHDwS", + }, + { + "title": "Búsqueda del Tesoro en el Almacén", + "learning_objective": + "Podrás localizar y nombrar distintos elementos del inventario al explorar imágenes de un almacén.", + "instructions": + "1. Cada participante recibirá una imagen diferente de un almacén.\n\n2. El Buscador describirá su imagen y pedirá ayuda para encontrar 3 objetos específicos. Por ejemplo: \"En mi imagen, veo muchas estanterías. ¿Pueden ayudarme a encontrar boxes of cereal, a forklift, and cleaning supplies?\"\n\n3. El Guía y el Contador trabajarán juntos para ayudar al Buscador a localizar los objetos. El Guía dará instrucciones de ubicación, mientras que el Contador llevará un registro de los objetos encontrados.\n\n4. Usen frases como:\n - \"I can see... near the...\"\n - \"Look for... next to...\"\n - \"The... is located...\"\n - \"We've found... items so far.\"\n\n5. Una vez que se encuentren los 3 objetos, el Buscador confirmará su ubicación en la imagen.\n\n6. Repitan el proceso para cada participante con nuevas imágenes y objetos.", + "vocab": [ + {"lemma": "warehouse", "pos": "NOUN"}, + {"lemma": "inventory", "pos": "NOUN"}, + {"lemma": "shelf", "pos": "NOUN"}, + {"lemma": "forklift", "pos": "NOUN"}, + {"lemma": "pallet", "pos": "NOUN"}, + {"lemma": "stock", "pos": "VERB"}, + {"lemma": "locate", "pos": "VERB"}, + {"lemma": "count", "pos": "VERB"}, + {"lemma": "organize", "pos": "VERB"}, + {"lemma": "storage", "pos": "NOUN"}, + ], + "roles": { + "988789f8-7936-48cf-b345-c527c753e78b": { + "name": "Buscador", + "id": "988789f8-7936-48cf-b345-c527c753e78b", + }, + "aa8dfbd8-f5e6-4689-9a2b-72aa03805cf4": { + "name": "Guía", + "id": "aa8dfbd8-f5e6-4689-9a2b-72aa03805cf4", + }, + "26740e72-b9ee-425c-8e04-8a7f43b41c9a": { + "name": "Contador", + "id": "26740e72-b9ee-425c-8e04-8a7f43b41c9a", + }, + }, + "req": { + "topic": "Inventario y conteo", + "mode": "Scavenger Hunt", + "objective": + "Podrás localizar y nombrar distintos elementos del inventario al explorar imágenes de un almacén.", + "media": "images", + "activity_cefr_level": "B2", + "language_of_instructions": "es", + "target_language": "en", + "number_of_participants": 3, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "1bckn3AcPg5kj1Gyrft0bW1v3VSjA3jjdNIf", + }, + { + "title": "Alerta de Seguridad en el Almacén", + "learning_objective": + "Serás capaz de describir y alertar sobre posibles riesgos de seguridad en el almacén mediante mensajes de voz.", + "instructions": + "En esta actividad, tú y tu compañero interpretarán los roles de Supervisor de Seguridad y Operario de Almacén. Utilizarán mensajes de voz para comunicarse sobre los riesgos de seguridad en el almacén.\n\nSupervisor de Seguridad: Tu tarea es realizar una inspección virtual del almacén. Identifica al menos tres posibles riesgos de seguridad y descríbelos detalladamente en mensajes de voz para el Operario de Almacén. Utiliza frases como \"I've noticed that...\", \"There's a potential hazard...\", \"We need to address...\"\n\nOperario de Almacén: Escucha atentamente los mensajes del Supervisor de Seguridad. Responde a cada riesgo identificado con un mensaje de voz, reconociendo el problema y sugiriendo una solución. Usa frases como \"I understand the concern about...\", \"To address this issue, we could...\", \"I'll make sure to...\"\n\nAmbos: Asegúrense de usar vocabulario específico relacionado con la seguridad en el almacén y de describir los riesgos y soluciones con claridad.", + "vocab": [ + {"lemma": "hazard", "pos": "NOUN"}, + {"lemma": "safety", "pos": "NOUN"}, + {"lemma": "risk", "pos": "NOUN"}, + {"lemma": "alert", "pos": "VERB"}, + {"lemma": "inspect", "pos": "VERB"}, + {"lemma": "secure", "pos": "VERB"}, + {"lemma": "dangerous", "pos": "ADJ"}, + {"lemma": "precaution", "pos": "NOUN"}, + {"lemma": "protocol", "pos": "NOUN"}, + {"lemma": "equipment", "pos": "NOUN"}, + ], + "roles": { + "6dde7299-4308-4cf9-9478-606598d62db9": { + "name": "Supervisor de Seguridad", + "id": "6dde7299-4308-4cf9-9478-606598d62db9", + }, + "83b38a9d-016a-4550-8f74-44fc1f1a5a1d": { + "name": "Operario de Almacén", + "id": "83b38a9d-016a-4550-8f74-44fc1f1a5a1d", + }, + }, + "req": { + "topic": "Seguridad y riesgos", + "mode": "Roleplay", + "objective": + "Serás capaz de describir y alertar sobre posibles riesgos de seguridad en el almacén mediante mensajes de voz.", + "media": "voice_messages", + "activity_cefr_level": "B2", + "language_of_instructions": "es", + "target_language": "en", + "number_of_participants": 2, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "U3GhjUyXZJ2Hw3xv3iUhObmSJiJaNHm1heQx", + }, + { + "title": "Enviando Mercancías: ¿Qué Unidad de Medida Usar?", + "learning_objective": + "Podrás discutir y decidir qué unidades de medida son más adecuadas para describir cantidades de envío.", + "instructions": + "Ustedes son parte de una empresa de logística internacional. Tienen que decidir las unidades de medida más apropiadas para varios productos que van a enviar.\n\n1. El Gerente de Logística presenta un producto y su cantidad.\n2. El Especialista en Embalaje sugiere una unidad de medida.\n3. El Coordinador de Envíos evalúa la sugerencia y propone alternativas si es necesario.\n\nDiscutan y lleguen a un acuerdo sobre la unidad más adecuada para cada producto. Usen frases como:\n- \"I think we should measure this in...\"\n- \"Wouldn't it be better to use... instead?\"\n- \"Let's consider... because...\"\n\nRepitan el proceso con diferentes productos. Recuerden justificar sus decisiones.", + "vocab": [ + {"lemma": "measure", "pos": "VERB"}, + {"lemma": "unit", "pos": "NOUN"}, + {"lemma": "quantity", "pos": "NOUN"}, + {"lemma": "appropriate", "pos": "ADJ"}, + {"lemma": "consider", "pos": "VERB"}, + {"lemma": "suggestion", "pos": "NOUN"}, + {"lemma": "evaluate", "pos": "VERB"}, + {"lemma": "alternative", "pos": "NOUN"}, + {"lemma": "justify", "pos": "VERB"}, + {"lemma": "decision", "pos": "NOUN"}, + ], + "roles": { + "89426e44-8b2c-47a2-83cd-9332b7bfb90b": { + "name": "Gerente de Logística", + "id": "89426e44-8b2c-47a2-83cd-9332b7bfb90b", + }, + "7a530966-e4b2-41c0-9216-b5677ad12002": { + "name": "Especialista en Embalaje", + "id": "7a530966-e4b2-41c0-9216-b5677ad12002", + }, + "61933251-fa7e-4bb5-9478-526da1bc1347": { + "name": "Coordinador de Envíos", + "id": "61933251-fa7e-4bb5-9478-526da1bc1347", + }, + }, + "req": { + "topic": "Unidades de medida y cantidades", + "mode": "Decision Making", + "objective": + "Podrás discutir y decidir qué unidades de medida son más adecuadas para describir cantidades de envío.", + "media": "nan", + "activity_cefr_level": "B2", + "language_of_instructions": "es", + "target_language": "en", + "number_of_participants": 3, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "1RVc2zUkGBeEJQqvWQ1TT5clDyXIJiV3dcDs", + }, + { + "title": "Intercambio de Información sobre Envíos", + "learning_objective": + "Al terminar, podrás intercambiar información detallada sobre procesos de envío usando el vocabulario específico.", + "instructions": + "Instrucciones:\n\n1. Uno de ustedes será un representante de servicio al cliente de una empresa de envíos, y el otro será un cliente que necesita enviar un paquete importante.\n\n2. Cliente: Prepara una lista de preguntas sobre el proceso de envío, incluyendo opciones, costos, tiempos de entrega y seguimiento del paquete.\n\n3. Representante: Prepárate para responder preguntas detalladas sobre los servicios de envío de tu empresa.\n\n4. Inicien una conversación donde el cliente hace preguntas y el representante proporciona información detallada.\n\n5. Usen el vocabulario específico de envíos en sus respuestas. Por ejemplo:\n - \"What are the shipping options available?\"\n - \"Our express delivery ensures your package arrives within 24 hours.\"\n - \"How can I track my shipment?\"\n - \"You'll receive a tracking number to monitor your parcel's progress.\"\n\n6. Continúen la conversación durante al menos 5 minutos, asegurándose de cubrir varios aspectos del proceso de envío.", + "vocab": [ + {"lemma": "shipment", "pos": "NOUN"}, + {"lemma": "tracking", "pos": "NOUN"}, + {"lemma": "delivery", "pos": "NOUN"}, + {"lemma": "customs", "pos": "NOUN"}, + {"lemma": "parcel", "pos": "NOUN"}, + {"lemma": "dispatch", "pos": "VERB"}, + {"lemma": "expedite", "pos": "VERB"}, + {"lemma": "insure", "pos": "VERB"}, + {"lemma": "declare", "pos": "VERB"}, + {"lemma": "express", "pos": "ADJ"}, + ], + "roles": { + "2fe223fd-c282-4978-b121-806538c1654a": { + "name": "Representante de Servicio al Cliente", + "id": "2fe223fd-c282-4978-b121-806538c1654a", + }, + "64ba6978-acb2-4c16-b16e-cd23bdf1d7ac": { + "name": "Cliente", + "id": "64ba6978-acb2-4c16-b16e-cd23bdf1d7ac", + }, + }, + "req": { + "topic": "Terminología de envíos", + "mode": "Conversation", + "objective": + "Al terminar, podrás intercambiar información detallada sobre procesos de envío usando el vocabulario específico.", + "media": "nan", + "activity_cefr_level": "B2", + "language_of_instructions": "es", + "target_language": "en", + "number_of_participants": 2, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "Af2fV0KWLFLOBRO6O9PQ5kuXUfmXDURssHJe", + } + ], + }, + { + "title": "Phrasal verbs clave", + "description": + "Te familiarizarás con business-related phrasal verbs como set up, follow up y sort out, y con phrasal verbs específicos del almacén como load up, stack up y ship out.", + "uuid": "65b2f557-556d-44f6-a87d-c1f6088181b2", + "activities": [ + { + "title": "Coordinación en el Almacén", + "learning_objective": + "Puedo usar 'load up', 'stack up' y 'ship out' en un diálogo para coordinar envíos en el almacén.", + "instructions": + "Tú y tu compañero trabajarán juntos en un almacén. Uno de ustedes será el Supervisor de Logística y el otro será el Operador de Almacén. Deben coordinar el proceso de carga, apilamiento y envío de mercancías utilizando las frases 'load up', 'stack up' y 'ship out'.\n\nSupervisor de Logística: Tu tarea es dirigir al Operador de Almacén sobre qué mercancías cargar, apilar y enviar. Usa las frases clave en tus instrucciones.\n\nOperador de Almacén: Tu trabajo es responder a las instrucciones del Supervisor, confirmando las acciones que estás realizando y haciendo preguntas si es necesario.\n\nEjemplo:\nSupervisor: \"We need to load up the trucks with electronics today.\"\nOperador: \"Understood. Should I start with the laptops or the smartphones?\"\nSupervisor: \"Let's stack up the smartphones first, then we'll load up the laptops.\"\nOperador: \"Got it. I'll stack up the smartphones now. When do we need to ship out?\"\nSupervisor: \"We need to ship out by 3 PM. Make sure everything is ready by then.\"\n\nMantengan una conversación fluida, utilizando las frases clave varias veces en contexto.", + "vocab": [ + {"lemma": "load up", "pos": "VERB"}, + {"lemma": "stack up", "pos": "VERB"}, + {"lemma": "ship out", "pos": "VERB"}, + {"lemma": "coordinate", "pos": "VERB"}, + {"lemma": "warehouse", "pos": "NOUN"}, + {"lemma": "logistics", "pos": "NOUN"}, + {"lemma": "merchandise", "pos": "NOUN"}, + {"lemma": "inventory", "pos": "NOUN"}, + {"lemma": "shipment", "pos": "NOUN"}, + {"lemma": "dispatch", "pos": "VERB"}, + ], + "roles": { + "e4d8ea64-c95e-4422-b52e-402df8235074": { + "name": "Supervisor de Logística", + "id": "e4d8ea64-c95e-4422-b52e-402df8235074", + }, + "1b99d0d5-c358-4348-9fa8-0a6703346221": { + "name": "Operador de Almacén", + "id": "1b99d0d5-c358-4348-9fa8-0a6703346221", + }, + }, + "req": { + "topic": "Práctica de diálogo con carga y envío", + "mode": "Roleplay", + "objective": + "Puedo usar 'load up', 'stack up' y 'ship out' en un diálogo para coordinar envíos en el almacén.", + "media": "nan", + "activity_cefr_level": "B2", + "language_of_instructions": "es", + "target_language": "en", + "number_of_participants": 2, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "LGthdxKN2TWUcHASXQYHCuE2GfAE00Rk2cb5", + }, + { + "title": "Adivina el Phrasal Verb de Negocios: 20 Preguntas", + "learning_objective": + "Puedo describir y adivinar phrasal verbs de negocios como 'set up', 'follow up' y 'sort out' mediante preguntas cerradas.", + "instructions": + "1. El Adivinador elige secretamente un phrasal verb de negocios de la lista proporcionada.\n\n2. Los Interrogadores harán turnos para hacer preguntas de sí/no sobre el phrasal verb. Por ejemplo:\n - \"¿Se usa este phrasal verb cuando se inicia un negocio?\"\n - \"¿Este phrasal verb implica contactar a alguien después de una reunión?\"\n - \"¿Se utiliza este phrasal verb para resolver problemas?\"\n\n3. El Adivinador solo puede responder \"Sí\" o \"No\" a las preguntas.\n\n4. Los Interrogadores tienen un máximo de 20 preguntas en total para adivinar el phrasal verb correcto.\n\n5. Si los Interrogadores adivinan correctamente antes de las 20 preguntas, ganan. Si no, el Adivinador gana.\n\n6. El Adivinador debe enviar una imagen relacionada con el phrasal verb elegido al principio del juego, sin revelar la respuesta.\n\n7. Al final, el Adivinador revela el phrasal verb y explica su significado y uso en inglés.\n\nPhrasal verbs para usar: set up, follow up, sort out, break down, call off, put forward, bring about, carry out, draw up, get across", + "vocab": [ + {"lemma": "set up", "pos": "VERB"}, + {"lemma": "follow up", "pos": "VERB"}, + {"lemma": "sort out", "pos": "VERB"}, + {"lemma": "break down", "pos": "VERB"}, + {"lemma": "call off", "pos": "VERB"}, + {"lemma": "put forward", "pos": "VERB"}, + {"lemma": "bring about", "pos": "VERB"}, + {"lemma": "carry out", "pos": "VERB"}, + {"lemma": "draw up", "pos": "VERB"}, + {"lemma": "get across", "pos": "VERB"}, + ], + "roles": { + "ca58fcc0-e256-470d-9f39-c62a9588048d": { + "name": "Adivinador", + "id": "ca58fcc0-e256-470d-9f39-c62a9588048d", + }, + "8fc7d6b7-2ec3-402c-8359-ab73fcbeda1b": { + "name": "Interrogador 1", + "id": "8fc7d6b7-2ec3-402c-8359-ab73fcbeda1b", + }, + "76b0d3c7-be80-4d76-a866-ee447fb192f9": { + "name": "Interrogador 2", + "id": "76b0d3c7-be80-4d76-a866-ee447fb192f9", + }, + }, + "req": { + "topic": "Adivina el phrasal verb de negocios", + "mode": "20-Question Game", + "objective": + "Puedo describir y adivinar phrasal verbs de negocios como 'set up', 'follow up' y 'sort out' mediante preguntas cerradas.", + "media": "images", + "activity_cefr_level": "B2", + "language_of_instructions": "es", + "target_language": "en", + "number_of_participants": 3, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "eYSQNGhsLLDEHBRQgodnEmZnWLVGvaJ6BrjC", + }, + { + "title": "Descifrando los usos de 'set up'", + "learning_objective": + "Puedo explicar las diferencias de significado de 'set up' en contextos como reuniones y montaje de equipos.", + "instructions": + "1. Cada uno de ustedes recibirá un rol: \"Organizador de eventos\" o \"Técnico de sonido\".\n\n2. Graben mensajes de voz describiendo cómo usan 'set up' en su trabajo diario. Por ejemplo:\n - Organizador: \"I often have to set up meetings with clients.\"\n - Técnico: \"I'm responsible for setting up the sound equipment for concerts.\"\n\n3. Después de escuchar el mensaje de su compañero, graben una respuesta explicando cómo el uso de 'set up' en su contexto es diferente. Por ejemplo:\n - Organizador: \"While I set up meetings, which means arranging or organizing them, you set up equipment, which means assembling or installing it.\"\n - Técnico: \"Your use of 'set up' is about planning and scheduling, but mine is about physically putting things together.\"\n\n4. Finalmente, graben un último mensaje de voz resumiendo ambos usos de 'set up' y explicando las diferencias clave.\n\nRecuerden usar 'set up' en diferentes tiempos verbales y estructuras para demostrar su versatilidad.", + "vocab": [ + {"lemma": "set up", "pos": "VERB"}, + {"lemma": "arrange", "pos": "VERB"}, + {"lemma": "organize", "pos": "VERB"}, + {"lemma": "assemble", "pos": "VERB"}, + {"lemma": "install", "pos": "VERB"}, + {"lemma": "meeting", "pos": "NOUN"}, + {"lemma": "equipment", "pos": "NOUN"}, + {"lemma": "context", "pos": "NOUN"}, + {"lemma": "difference", "pos": "NOUN"}, + ], + "roles": { + "c6fa4b00-82f6-42ee-ac12-ff4c0acec676": { + "name": "Organizador de eventos", + "id": "c6fa4b00-82f6-42ee-ac12-ff4c0acec676", + }, + "6e6389f4-4411-4291-8279-5ce3b7dd3820": { + "name": "Técnico de sonido", + "id": "6e6389f4-4411-4291-8279-5ce3b7dd3820", + }, + }, + "req": { + "topic": "Comparación de usos de 'set up'", + "mode": "Conversation", + "objective": + "Puedo explicar las diferencias de significado de 'set up' en contextos como reuniones y montaje de equipos.", + "media": "voice_messages", + "activity_cefr_level": "B2", + "language_of_instructions": "es", + "target_language": "en", + "number_of_participants": 2, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "gJXgBsYNfJLWpumbsFhFq60ewsKBzE37OSTs", + }, + { + "title": "Búsqueda de Phrasal Verbs en el Almacén", + "learning_objective": + "Puedo identificar ejemplos de 'load up', 'stack up' y 'ship out' al explorar diferentes áreas del almacén.", + "instructions": + "Bienvenidos a la Búsqueda de Phrasal Verbs en el Almacén. En esta actividad, explorarán un almacén virtual para encontrar ejemplos de los phrasal verbs 'load up', 'stack up' y 'ship out'.\n\n1. El Gerente del Almacén dirigirá la búsqueda y dará instrucciones sobre dónde buscar.\n2. El Asistente de Carga buscará ejemplos de 'load up' (por ejemplo, \"We need to load up the truck with these boxes\").\n3. El Organizador de Inventario buscará ejemplos de 'stack up' (por ejemplo, \"Let's stack up these crates neatly\").\n\nCada uno de ustedes debe encontrar al menos 3 ejemplos de su phrasal verb asignado. Compartan sus hallazgos en el chat grupal y discutan cómo se utilizan en el contexto del almacén.\n\nRecuerden: 'Load up' significa cargar o llenar algo, 'stack up' significa apilar o acumular, y 'ship out' significa enviar o despachar.\n\n¡Buena suerte en su búsqueda de phrasal verbs!", + "vocab": [ + {"lemma": "load up", "pos": "VERB"}, + {"lemma": "stack up", "pos": "VERB"}, + {"lemma": "ship out", "pos": "VERB"}, + {"lemma": "warehouse", "pos": "NOUN"}, + {"lemma": "inventory", "pos": "NOUN"}, + {"lemma": "crate", "pos": "NOUN"}, + {"lemma": "pallet", "pos": "NOUN"}, + {"lemma": "forklift", "pos": "NOUN"}, + {"lemma": "dispatch", "pos": "VERB"}, + {"lemma": "storage", "pos": "NOUN"}, + ], + "roles": { + "69316d9c-93ad-41ba-940b-2c4806a6da24": { + "name": "Gerente del Almacén", + "id": "69316d9c-93ad-41ba-940b-2c4806a6da24", + }, + "35a160ef-ebf9-41c1-b669-42ffbd795cdf": { + "name": "Asistente de Carga", + "id": "35a160ef-ebf9-41c1-b669-42ffbd795cdf", + }, + "8c19976e-f8fe-4eb3-8968-d737e160ceb1": { + "name": "Organizador de Inventario", + "id": "8c19976e-f8fe-4eb3-8968-d737e160ceb1", + }, + }, + "req": { + "topic": "Búsqueda de phrasal verbs en el almacén", + "mode": "Scavenger Hunt", + "objective": + "Puedo identificar ejemplos de 'load up', 'stack up' y 'ship out' al explorar diferentes áreas del almacén.", + "media": "nan", + "activity_cefr_level": "B2", + "language_of_instructions": "es", + "target_language": "en", + "number_of_participants": 3, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "siS7MIA1C2NDiRUYbLZL69gFtpLe5Fs4ghrO", + }, + { + "title": "Resolviendo Problemas de Inventario", + "learning_objective": + "Puedo colaborar para 'sort out' incidencias de inventario y 'follow up' con proveedores para tomar decisiones de envío.", + "instructions": + "Ustedes son un equipo de gestión de inventario en una gran empresa de comercio electrónico. Han descubierto una discrepancia significativa en el inventario de un producto popular y necesitan resolver el problema rápidamente.\n\n1. Gerente de Inventario: Comience explicando la situación al equipo. Use frases como \"We've noticed a discrepancy in our inventory\" y \"We need to sort this out quickly\".\n\n2. Analista de Datos: Proporcione detalles sobre la discrepancia. Use expresiones como \"According to our records\" y \"The data shows that\".\n\n3. Coordinador de Proveedores: Sugiera contactar al proveedor para obtener más información. Utilice frases como \"We should follow up with the supplier\" y \"Let's get in touch with them to clarify\".\n\n4. Gerente de Logística: Proponga soluciones para el envío y manejo del inventario. Use expresiones como \"We could expedite the shipping\" y \"Let's consider alternative delivery options\".\n\nColaboren para tomar decisiones sobre cómo resolver el problema y seguir adelante. Asegúrense de usar el vocabulario objetivo en sus discusiones.", + "vocab": [ + {"lemma": "sort out", "pos": "VERB"}, + {"lemma": "follow up", "pos": "VERB"}, + {"lemma": "discrepancy", "pos": "NOUN"}, + {"lemma": "inventory", "pos": "NOUN"}, + {"lemma": "supplier", "pos": "NOUN"}, + {"lemma": "expedite", "pos": "VERB"}, + {"lemma": "clarify", "pos": "VERB"}, + {"lemma": "records", "pos": "NOUN"}, + ], + "roles": { + "efb1aed9-3820-4d0f-948f-8744b85f5a30": { + "name": "Gerente de Inventario", + "id": "efb1aed9-3820-4d0f-948f-8744b85f5a30", + }, + "26552b36-4c5f-4595-b75d-976de42a7d62": { + "name": "Analista de Datos", + "id": "26552b36-4c5f-4595-b75d-976de42a7d62", + }, + "9059c35f-05ad-487c-90ae-7984f57204f2": { + "name": "Coordinador de Proveedores", + "id": "9059c35f-05ad-487c-90ae-7984f57204f2", + }, + "7a3407d2-d6df-4b52-be4f-013524030a4b": { + "name": "Gerente de Logística", + "id": "7a3407d2-d6df-4b52-be4f-013524030a4b", + }, + }, + "req": { + "topic": "Resolución de problemas e seguimiento", + "mode": "Decision Making", + "objective": + "Puedo colaborar para 'sort out' incidencias de inventario y 'follow up' con proveedores para tomar decisiones de envío.", + "media": "nan", + "activity_cefr_level": "B2", + "language_of_instructions": "es", + "target_language": "en", + "number_of_participants": 4, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "AWkloe9LF2yzY3THRuWknb8EWskUcEU3SlHm", + } + ], + }, + { + "title": "Comunicación escrita profesional", + "description": + "Practicarás la redacción de mensajes formales en distintos formatos: emails a clientes y proveedores, memorandos internos, avisos para el personal y resúmenes de SOP con claridad y tono apropiado.", + "uuid": "ae5d6f23-8a1f-4ff5-bc56-b9ba0e04d361", + "activities": [ + { + "title": "Retraso en la Entrega: Comunicación Profesional", + "learning_objective": + "Puedo redactar y enviar un email formal a un cliente explicando un retraso en la entrega y proponiendo soluciones.", + "instructions": + "1. Tú eres el gerente de una empresa que ha experimentado un retraso en la entrega de un producto importante.\n\n2. Tu compañero es el cliente que está esperando la entrega.\n\n3. Redacta un email formal en inglés explicando la situación al cliente. Incluye:\n - Una disculpa formal (e.g., \"We sincerely apologize for the delay...\")\n - La razón del retraso (e.g., \"Due to unexpected supply chain issues...\")\n - Una propuesta de solución (e.g., \"We propose the following solutions...\")\n - Una fecha estimada de entrega (e.g., \"We expect to complete the delivery by...\")\n\n4. Envía el email a tu compañero (el cliente).\n\n5. El cliente debe responder al email, expresando su comprensión o preocupación, y posiblemente solicitando más información o garantías.\n\n6. Continúa la conversación por email, asegurándote de mantener un tono formal y profesional en todo momento.\n\nRecuerda: Utiliza un lenguaje formal y profesional apropiado para la comunicación empresarial en inglés.", + "vocab": [ + {"lemma": "delay", "pos": "NOUN"}, + {"lemma": "apologize", "pos": "VERB"}, + {"lemma": "sincerely", "pos": "ADV"}, + {"lemma": "propose", "pos": "VERB"}, + {"lemma": "solution", "pos": "NOUN"}, + {"lemma": "inconvenience", "pos": "NOUN"}, + {"lemma": "estimated", "pos": "ADJ"}, + {"lemma": "delivery", "pos": "NOUN"}, + {"lemma": "appreciate", "pos": "VERB"}, + {"lemma": "understanding", "pos": "NOUN"}, + ], + "roles": { + "79eb335b-5dec-482c-91a6-06fb85953d0a": { + "name": "Gerente de la empresa", + "id": "79eb335b-5dec-482c-91a6-06fb85953d0a", + }, + "d1c78e88-a38b-448f-a41f-aafcf47a3d67": { + "name": "Cliente", + "id": "d1c78e88-a38b-448f-a41f-aafcf47a3d67", + }, + }, + "req": { + "topic": + "Redacción de emails formales ante retrasos en la entrega", + "mode": "Roleplay", + "objective": + "Puedo redactar y enviar un email formal a un cliente explicando un retraso en la entrega y proponiendo soluciones.", + "media": "nan", + "activity_cefr_level": "B2", + "language_of_instructions": "es", + "target_language": "en", + "number_of_participants": 2, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "G4rhfE4bM46eFZB3rgdFrhCB5eoRLwzjwAHQ", + }, + { + "title": "Creando el Email Perfecto", + "learning_objective": + "Puedo elegir líneas de asunto apropiadas y organizar la estructura de un email profesional para diversos escenarios.", + "instructions": + "En esta actividad, cada uno de ustedes tendrá un rol específico en una situación profesional. Su tarea es crear un email apropiado para esa situación, enfocándose en elegir una línea de asunto efectiva y estructurar el email correctamente.\n\n1. El Gerente de Proyecto enviará un email al Cliente para informar sobre el progreso del proyecto y solicitar una reunión.\n2. El Representante de Ventas escribirá un email al Gerente de Marketing para proponer una nueva estrategia de ventas.\n3. El Asistente Administrativo redactará un email al equipo de la oficina sobre cambios en las políticas de la empresa.\n\nPara cada email, asegúrense de:\n- Elegir una línea de asunto clara y concisa (e.g., \"Project Update and Meeting Request\")\n- Incluir un saludo apropiado (e.g., \"Dear Mr./Ms. [Last Name],\" or \"Hello [First Name],\")\n- Estructurar el cuerpo del email con una introducción, detalles principales y una conclusión clara\n- Terminar con una despedida profesional (e.g., \"Best regards,\" \"Sincerely,\")\n\nDespués de escribir sus emails, compártanlos en el chat y discutan por qué eligieron esa estructura y línea de asunto.", + "vocab": [ + {"lemma": "subject line", "pos": "NOUN"}, + {"lemma": "structure", "pos": "NOUN"}, + {"lemma": "professional", "pos": "ADJ"}, + {"lemma": "concise", "pos": "ADJ"}, + {"lemma": "appropriate", "pos": "ADJ"}, + {"lemma": "progress", "pos": "NOUN"}, + {"lemma": "strategy", "pos": "NOUN"}, + {"lemma": "policy", "pos": "NOUN"}, + {"lemma": "update", "pos": "VERB"}, + {"lemma": "propose", "pos": "VERB"}, + ], + "roles": { + "2f86196d-c7f2-439f-82bb-7132ca8749af": { + "name": "Gerente de Proyecto", + "id": "2f86196d-c7f2-439f-82bb-7132ca8749af", + }, + "9a84170c-819d-46f9-a936-57529de46932": { + "name": "Representante de Ventas", + "id": "9a84170c-819d-46f9-a936-57529de46932", + }, + "8bbb211f-0e70-4235-a8e6-4bff7891c146": { + "name": "Asistente Administrativo", + "id": "8bbb211f-0e70-4235-a8e6-4bff7891c146", + }, + }, + "req": { + "topic": "Selección de asuntos y estructura de email", + "mode": "Decision Making", + "objective": + "Puedo elegir líneas de asunto apropiadas y organizar la estructura de un email profesional para diversos escenarios.", + "media": "nan", + "activity_cefr_level": "B2", + "language_of_instructions": "es", + "target_language": "en", + "number_of_participants": 3, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "2grXPdU7OuhyTCxEYvCx1sIRFFTYSrOdHPgD", + }, + { + "title": "Caza del Tesoro de Errores en Memorandos", + "learning_objective": + "Puedo identificar y corregir errores de tono, formato y gramática en memorandos internos.", + "instructions": + "1. Cada participante recibirá imágenes de memorandos internos con errores de tono, formato y gramática.\n\n2. Busca y identifica al menos 3 errores en cada memorando. Pueden ser errores como:\n - Inappropriate tone: \"Hey boss, what's up?\"\n - Formatting issues: Falta de saludo o cierre formal\n - Grammar mistakes: \"The meeting have been rescheduled\"\n\n3. Comparte tus hallazgos con tu compañero, explicando por qué crees que son errores.\n\n4. Juntos, discutan y propongan correcciones para cada error encontrado.\n\n5. Finalmente, reescribe una versión corregida del memorando y compártela con tu compañero.\n\nRecuerda mantener un tono profesional y utilizar el formato adecuado para memorandos internos.", + "vocab": [ + {"lemma": "memo", "pos": "NOUN"}, + {"lemma": "internal", "pos": "ADJ"}, + {"lemma": "tone", "pos": "NOUN"}, + {"lemma": "format", "pos": "NOUN"}, + {"lemma": "grammar", "pos": "NOUN"}, + {"lemma": "identify", "pos": "VERB"}, + {"lemma": "correct", "pos": "VERB"}, + {"lemma": "error", "pos": "NOUN"}, + {"lemma": "professional", "pos": "ADJ"}, + {"lemma": "appropriate", "pos": "ADJ"}, + ], + "roles": { + "e46303e3-f0ca-4032-8222-0c1da83bf9e2": { + "name": "Cazador de Errores", + "id": "e46303e3-f0ca-4032-8222-0c1da83bf9e2", + }, + "e1b0af32-6e9b-4743-bf66-e9924a9d0972": { + "name": "Cazador de Errores", + "id": "e1b0af32-6e9b-4743-bf66-e9924a9d0972", + }, + }, + "req": { + "topic": "Corrección de memorandos internos", + "mode": "Scavenger Hunt", + "objective": + "Puedo identificar y corregir errores de tono, formato y gramática en memorandos internos.", + "media": "images", + "activity_cefr_level": "B2", + "language_of_instructions": "es", + "target_language": "en", + "number_of_participants": 2, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "bmLq6gcmrUKv8WqkvUl0fuorToYkqHnSIMvb", + }, + { + "title": "Notificaciones internas sobre nuevos protocolos", + "learning_objective": + "Puedo redactar y discutir informaciones formales para notificaciones internas usando mensajes de voz.", + "instructions": + "Ustedes simulan un intercambio de mensajes de voz en inglés entre un Supervisor y un Empleado. Primero, el Supervisor envía un voice message anunciando el nuevo protocolo y sus detalles. Usa frases formales como “Hello team, I would like to inform you that…” o “Please be advised that…”. Después, el Empleado responde con preguntas o comentarios formales para aclarar dudas: “Could you please clarify…?” o “I would appreciate further information on…”. Grabad al menos tres mensajes cada uno y compartidlos en el chat. Enfóquense en mantener un registro formal y transmitir la información con claridad.", + "vocab": [ + {"lemma": "notification", "pos": "NOUN"}, + {"lemma": "protocol", "pos": "NOUN"}, + {"lemma": "implement", "pos": "VERB"}, + {"lemma": "deadline", "pos": "NOUN"}, + {"lemma": "feedback", "pos": "NOUN"}, + {"lemma": "clarify", "pos": "VERB"}, + ], + "roles": { + "bec0299b-44d3-4db2-bde1-9dd1e2c06e93": { + "name": "Supervisor", + "id": "bec0299b-44d3-4db2-bde1-9dd1e2c06e93", + }, + "0b4fceaf-6a9e-4ae2-89fd-3ca453506672": { + "name": "Empleado", + "id": "0b4fceaf-6a9e-4ae2-89fd-3ca453506672", + }, + }, + "req": { + "topic": + "Elaboración de notificaciones al personal sobre nuevos protocolos", + "mode": "Conversation", + "objective": + "Puedo redactar y discutir informaciones formales para notificaciones internas usando mensajes de voz.", + "media": "voice_messages", + "activity_cefr_level": "B2", + "language_of_instructions": "es", + "target_language": "en", + "number_of_participants": 2, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "ReXvxwclDXsGz1lsu5KPIwgCP6apCAyJQiTw", + }, + { + "title": + "Debate sobre resúmenes de procedimientos operativos estándar", + "learning_objective": + "Puedo resumir procedimientos estándar y defender la claridad y coherencia de mi resumen en un debate con compañeros.", + "instructions": + "1. Cada participante recibirá un procedimiento operativo estándar (SOP) diferente.\n\n2. Resumid vuestro SOP en inglés, enfocándoos en los puntos clave y la estructura lógica.\n\n3. Presentad vuestro resumen al grupo.\n\n4. Después de cada presentación, los otros participantes harán preguntas y comentarios sobre la claridad y coherencia del resumen.\n\n5. Defended vuestro resumen, explicando vuestras decisiones sobre qué incluir y cómo estructurarlo.\n\n6. Al final, votad por el resumen más claro y coherente.\n\nFrases útiles en inglés:\n- \"The key points of this SOP are...\"\n- \"I structured my summary by...\"\n- \"Could you clarify the part about...?\"\n- \"I believe my summary is coherent because...\"\n- \"In my opinion, this summary could be improved by...\"", + "vocab": [ + {"lemma": "summarize", "pos": "VERB"}, + {"lemma": "procedure", "pos": "NOUN"}, + {"lemma": "coherence", "pos": "NOUN"}, + {"lemma": "clarity", "pos": "NOUN"}, + {"lemma": "defend", "pos": "VERB"}, + {"lemma": "structure", "pos": "VERB"}, + {"lemma": "key point", "pos": "NOUN"}, + {"lemma": "clarify", "pos": "VERB"}, + {"lemma": "improve", "pos": "VERB"}, + {"lemma": "logical", "pos": "ADJ"}, + ], + "roles": { + "fcd275c9-031a-4d36-abe7-8edd1633f948": { + "name": "Presentador", + "id": "fcd275c9-031a-4d36-abe7-8edd1633f948", + }, + "e8bda486-a2cf-4986-80b6-a96dbe08aba3": { + "name": "Crítico", + "id": "e8bda486-a2cf-4986-80b6-a96dbe08aba3", + }, + "d37a9ffa-5e5a-4372-9348-8d7658212b04": { + "name": "Moderador", + "id": "d37a9ffa-5e5a-4372-9348-8d7658212b04", + }, + }, + "req": { + "topic": "Redacción de resúmenes de SOP", + "mode": "Debate", + "objective": + "Puedo resumir procedimientos estándar y defender la claridad y coherencia de mi resumen en un debate con compañeros.", + "media": "nan", + "activity_cefr_level": "B2", + "language_of_instructions": "es", + "target_language": "en", + "number_of_participants": 3, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "pMR6ngpo8woLQHwQuKAw6093F7ymgrYGTw3v", + } + ], + }, + { + "title": "9. Lenguaje de procesos y operaciones", + "description": + "Aprenderás processing terms, cómo explicar workflows y procedures, y a describir system o software processes con precisión en inglés.", + "uuid": "3485e94b-7358-4612-9a90-380bf2fcdeec", + "activities": [ + { + "title": "Simulación de Gestión de Almacén", + "learning_objective": + "Puedo explicar paso a paso un flujo de trabajo en un sistema de gestión de almacén usando vocabulario técnico preciso.", + "instructions": + "Tú eres un gerente de almacén explicando el proceso de recepción y almacenamiento de mercancías a un nuevo empleado. Sigue estos pasos:\n\n1. Saluda al nuevo empleado y preséntate.\n2. Explica el proceso paso a paso, utilizando vocabulario técnico.\n3. Incluye al menos 5 pasos en tu explicación.\n4. Envía una imagen que ilustre cada paso del proceso.\n5. Concluye preguntando si el nuevo empleado tiene alguna duda.\n\nEjemplo de frases en inglés:\n\"First, we receive the goods at the loading dock.\"\n\"Next, we scan the barcodes to update our inventory management system.\"\n\"Then, we use the forklift to move pallets to their designated storage locations.\"", + "vocab": [ + {"lemma": "warehouse", "pos": "NOUN"}, + {"lemma": "inventory", "pos": "NOUN"}, + {"lemma": "pallet", "pos": "NOUN"}, + {"lemma": "forklift", "pos": "NOUN"}, + {"lemma": "barcode", "pos": "NOUN"}, + {"lemma": "scan", "pos": "VERB"}, + {"lemma": "receive", "pos": "VERB"}, + {"lemma": "store", "pos": "VERB"}, + {"lemma": "track", "pos": "VERB"}, + {"lemma": "efficient", "pos": "ADJ"}, + ], + "roles": { + "56a5b2c0-fe94-47c4-af84-2f782efb0133": { + "name": "Gerente de Almacén", + "id": "56a5b2c0-fe94-47c4-af84-2f782efb0133", + }, + "33612fb1-57d4-467e-8290-9e14e411463b": { + "name": "Nuevo Empleado", + "id": "33612fb1-57d4-467e-8290-9e14e411463b", + }, + }, + "req": { + "topic": "Explicación de workflows", + "mode": "Roleplay", + "objective": + "Puedo explicar paso a paso un flujo de trabajo en un sistema de gestión de almacén usando vocabulario técnico preciso.", + "media": "images", + "activity_cefr_level": "B2", + "language_of_instructions": "es", + "target_language": "en", + "number_of_participants": 2, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "BkRxjlgCFfakIYAimptdG5fLq7SbF4cpUzHM", + }, + { + "title": "Optimización del Proceso de Producción", + "learning_objective": + "Puedo discutir y decidir cambios para optimizar un proceso operativo y justificar mis propuestas en inglés.", + "instructions": + "Ustedes son parte de un equipo de producción en una fábrica de juguetes. Han notado ineficiencias en el proceso de ensamblaje y necesitan proponer mejoras. Cada uno de ustedes tiene un rol específico en el equipo.\n\n1. Escuchen el mensaje de voz inicial del Gerente de Producción (el profesor) explicando la situación actual y pidiendo propuestas de mejora.\n\n2. Graben un mensaje de voz de 1-2 minutos cada uno, en inglés, proponiendo un cambio para optimizar el proceso y justificando su propuesta. Usen frases como:\n - \"I suggest we...\"\n - \"This change would improve... because...\"\n - \"The benefits of this modification include...\"\n\n3. Después de escuchar las propuestas de los demás, graben otro mensaje de voz de 1-2 minutos discutiendo las ideas presentadas y llegando a un consenso sobre qué cambios implementar. Usen frases como:\n - \"I agree/disagree with... because...\"\n - \"Building on your idea, we could also...\"\n - \"Taking everything into account, I think we should...\"\n\n4. Finalmente, el Ingeniero de Procesos preparará un mensaje de voz de 2-3 minutos resumiendo las decisiones tomadas y explicando cómo se implementarán los cambios.\n\nRecuerden usar el vocabulario relacionado con la mejora de procesos y mantener un tono profesional y colaborativo en sus mensajes.", + "vocab": [ + {"lemma": "optimize", "pos": "VERB"}, + {"lemma": "efficiency", "pos": "NOUN"}, + {"lemma": "streamline", "pos": "VERB"}, + {"lemma": "process", "pos": "NOUN"}, + {"lemma": "implement", "pos": "VERB"}, + {"lemma": "improvement", "pos": "NOUN"}, + {"lemma": "productivity", "pos": "NOUN"}, + {"lemma": "workflow", "pos": "NOUN"}, + {"lemma": "bottleneck", "pos": "NOUN"}, + {"lemma": "cost-effective", "pos": "ADJ"}, + ], + "roles": { + "85808006-f0ae-4c35-a98a-655a648b7690": { + "name": "Supervisor de Línea", + "id": "85808006-f0ae-4c35-a98a-655a648b7690", + }, + "096950ec-7c22-4d4a-b4cd-1f6f811deaeb": { + "name": "Operador de Máquina", + "id": "096950ec-7c22-4d4a-b4cd-1f6f811deaeb", + }, + "a094d290-076f-4149-b6cd-3a18c8cb26fb": { + "name": "Ingeniero de Procesos", + "id": "a094d290-076f-4149-b6cd-3a18c8cb26fb", + }, + }, + "req": { + "topic": "Mejora de procesos", + "mode": "Decision Making", + "objective": + "Puedo discutir y decidir cambios para optimizar un proceso operativo y justificar mis propuestas en inglés.", + "media": "voice_messages", + "activity_cefr_level": "B2", + "language_of_instructions": "es", + "target_language": "en", + "number_of_participants": 3, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "hzTGRw6gZfd23RalFsPsNtcME6szqxoUEjJr", + }, + { + "title": "Juego de las 20 Preguntas: Términos de Procesamiento", + "learning_objective": + "Puedo identificar y describir términos clave de procesamiento haciendo preguntas en inglés de forma eficaz.", + "instructions": + "1. Un participante (el \"Adivinador\") piensa en un término de procesamiento de la lista de vocabulario.\n\n2. El otro participante (el \"Interrogador\") hace hasta 20 preguntas de sí o no en inglés para adivinar el término. Por ejemplo:\n - \"Is it a type of machine?\"\n - \"Does it involve heat?\"\n - \"Is it used in food production?\"\n\n3. El Adivinador solo puede responder \"yes\" o \"no\".\n\n4. Si el Interrogador adivina correctamente antes de las 20 preguntas, gana. Si no, el Adivinador gana.\n\n5. Después de cada ronda, discutan brevemente en inglés sobre el término y su importancia en el procesamiento.\n\nRecuerden usar inglés durante todo el juego para practicar la formulación de preguntas y la descripción de términos de procesamiento.", + "vocab": [ + {"lemma": "processing", "pos": "NOUN"}, + {"lemma": "fermentation", "pos": "NOUN"}, + {"lemma": "pasteurization", "pos": "NOUN"}, + {"lemma": "distillation", "pos": "NOUN"}, + {"lemma": "homogenization", "pos": "NOUN"}, + {"lemma": "sterilization", "pos": "NOUN"}, + {"lemma": "filtration", "pos": "NOUN"}, + {"lemma": "emulsification", "pos": "NOUN"}, + {"lemma": "dehydration", "pos": "NOUN"}, + {"lemma": "preservation", "pos": "NOUN"}, + ], + "roles": { + "c469cd32-019f-4297-8318-5f82fd2d2446": { + "name": "Interrogador", + "id": "c469cd32-019f-4297-8318-5f82fd2d2446", + }, + "d5767a87-f1b7-40bc-8bcb-aa48991302d9": { + "name": "Adivinador", + "id": "d5767a87-f1b7-40bc-8bcb-aa48991302d9", + }, + }, + "req": { + "topic": "Terminología de procesamiento", + "mode": "20-Question Game", + "objective": + "Puedo identificar y describir términos clave de procesamiento haciendo preguntas en inglés de forma eficaz.", + "media": "nan", + "activity_cefr_level": "B2", + "language_of_instructions": "es", + "target_language": "en", + "number_of_participants": 2, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "OqFf08S5gfO9VKeP0wgsklj7f1HgzkL8EHxM", + }, + { + "title": "Búsqueda del Tesoro en el Manual", + "learning_objective": + "Puedo localizar y explicar términos relacionados con procedimientos en un documento en inglés.", + "instructions": + "En esta actividad, participarán en una búsqueda del tesoro virtual utilizando un manual en inglés. Cada uno de ustedes tendrá un rol específico:\n\n1. El Buscador: Tu tarea es encontrar términos específicos relacionados con procedimientos en el manual.\n2. El Explicador: Deberás explicar el significado y uso de los términos encontrados.\n3. El Verificador: Tu rol es confirmar si las explicaciones son correctas y completas.\n\nPasos:\n1. El Buscador tiene 2 minutos para encontrar un término relacionado con procedimientos en el manual y compartirlo en el chat.\nEjemplo: \"I found the term 'troubleshooting' in section 5 of the manual.\"\n\n2. El Explicador tiene 2 minutos para explicar el significado y uso del término.\nEjemplo: \"Troubleshooting refers to the process of identifying and solving problems or issues, especially in a technical system.\"\n\n3. El Verificador tiene 1 minuto para confirmar si la explicación es correcta y completa, o para añadir información si es necesario.\nEjemplo: \"That's correct. I'd add that troubleshooting often involves a step-by-step approach to diagnose and fix issues.\"\n\n4. Repitan este proceso con diferentes términos durante 15 minutos, rotando los roles cada 5 minutos.\n\nRecuerden: Usen inglés para los términos y explicaciones, pero pueden usar español para clarificar o hacer preguntas si es necesario.", + "vocab": [ + {"lemma": "troubleshoot", "pos": "VERB"}, + {"lemma": "procedure", "pos": "NOUN"}, + {"lemma": "implement", "pos": "VERB"}, + {"lemma": "configure", "pos": "VERB"}, + {"lemma": "maintenance", "pos": "NOUN"}, + {"lemma": "diagnostic", "pos": "ADJ"}, + {"lemma": "calibrate", "pos": "VERB"}, + {"lemma": "malfunction", "pos": "NOUN"}, + {"lemma": "protocol", "pos": "NOUN"}, + {"lemma": "optimize", "pos": "VERB"}, + ], + "roles": { + "c8601c8f-511d-4e39-81da-97d362a68f30": { + "name": "Buscador", + "id": "c8601c8f-511d-4e39-81da-97d362a68f30", + }, + "c475aa03-6b33-4fc2-858f-c7fd5f14f0a9": { + "name": "Explicador", + "id": "c475aa03-6b33-4fc2-858f-c7fd5f14f0a9", + }, + "bc1467d9-5d17-43d5-ba27-02753e56720d": { + "name": "Verificador", + "id": "bc1467d9-5d17-43d5-ba27-02753e56720d", + }, + }, + "req": { + "topic": "Búsqueda de vocabulario en manuales", + "mode": "Scavenger Hunt", + "objective": + "Puedo localizar y explicar términos relacionados con procedimientos en un documento en inglés.", + "media": "nan", + "activity_cefr_level": "B2", + "language_of_instructions": "es", + "target_language": "en", + "number_of_participants": 3, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "hi7KIGsUnZwpjK4qbncjmUD6Q7LWyrgTAjOJ", + }, + { + "title": "Diálogo sobre el Proceso de Recepción y Almacenamiento", + "learning_objective": + "Puedo conversar sobre las etapas de un proceso de recepción y almacenamiento con fluidez y coherencia.", + "instructions": + "Tú y tu compañero van a simular una conversación entre un gerente de almacén y un nuevo empleado. El gerente explicará el proceso de recepción y almacenamiento, mientras que el nuevo empleado hará preguntas para entender mejor cada etapa.\n\nGerente: Explica las etapas del proceso de recepción y almacenamiento. Usa frases como \"First, we...\", \"Next, we...\", \"Then, we...\", \"Finally, we...\".\n\nNuevo empleado: Haz preguntas sobre cada etapa para obtener más detalles. Usa frases como \"Could you explain...?\", \"What happens if...?\", \"How do we...?\".\n\nAmbos: Usen el vocabulario proporcionado en sus explicaciones y preguntas. Mantengan una conversación fluida y coherente, asegurándose de cubrir todas las etapas del proceso.", + "vocab": [ + {"lemma": "receive", "pos": "VERB"}, + {"lemma": "inspect", "pos": "VERB"}, + {"lemma": "unload", "pos": "VERB"}, + {"lemma": "sort", "pos": "VERB"}, + {"lemma": "store", "pos": "VERB"}, + {"lemma": "inventory", "pos": "NOUN"}, + {"lemma": "warehouse", "pos": "NOUN"}, + {"lemma": "shipment", "pos": "NOUN"}, + {"lemma": "pallet", "pos": "NOUN"}, + {"lemma": "forklift", "pos": "NOUN"}, + ], + "roles": { + "cdb396c9-c682-4155-b3a5-43c7959e668e": { + "name": "Gerente de Almacén", + "id": "cdb396c9-c682-4155-b3a5-43c7959e668e", + }, + "6d8252f3-0ffe-41cc-b275-d13943ca3ca9": { + "name": "Nuevo Empleado", + "id": "6d8252f3-0ffe-41cc-b275-d13943ca3ca9", + }, + }, + "req": { + "topic": "Descripción de procesos operativos", + "mode": "Conversation", + "objective": + "Puedo conversar sobre las etapas de un proceso de recepción y almacenamiento con fluidez y coherencia.", + "media": "nan", + "activity_cefr_level": "B2", + "language_of_instructions": "es", + "target_language": "en", + "number_of_participants": 2, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "UL9QhSCzEVtyCsRq7XyLNY4TYOVp7R5m49eu", + } + ], + }, + { + "title": "Comunicación de emergencias y seguridad", + "description": + "Vas a manejar el reporting de accidents o hazards, dar y recibir instrucciones urgentes y comunicarte con emergency services usando el registro adecuado.", + "uuid": "cf8692f1-c3cc-4969-8f90-8a4d289a55af", + "activities": [ + { + "title": "Reporte Formal de Accidentes", + "learning_objective": + "Puedo reportar con precisión un accidente o peligro, describir su impacto y usar conectores para organizar la información.", + "instructions": + "1. Tú, Supervisor, solicita detalles del accidente con preguntas claras, por ejemplo: “What happened exactly?” o “How severe was the impact?”\n2. Tú, Testigo, describe el incidente con precisión, menciona su impacto y posibles causas usando conectores como however, therefore, moreover.\n3. Juntos, organicen la información y redacten un breve informe formal (3–4 oraciones) integrando todos los datos.", + "vocab": [ + {"lemma": "incident", "pos": "NOUN"}, + {"lemma": "hazard", "pos": "NOUN"}, + {"lemma": "impact", "pos": "NOUN"}, + {"lemma": "consequence", "pos": "NOUN"}, + {"lemma": "cause", "pos": "NOUN"}, + {"lemma": "therefore", "pos": "ADVERB"}, + {"lemma": "however", "pos": "ADVERB"}, + {"lemma": "moreover", "pos": "ADVERB"}, + {"lemma": "report", "pos": "VERB"}, + {"lemma": "describe", "pos": "VERB"}, + ], + "roles": { + "053c96f3-6661-4ef3-a452-d9063f8c6dc8": { + "name": "Supervisor", + "id": "053c96f3-6661-4ef3-a452-d9063f8c6dc8", + }, + "e4c5ea40-6c1b-4605-907d-b4580193f5d8": { + "name": "Testigo", + "id": "e4c5ea40-6c1b-4605-907d-b4580193f5d8", + }, + }, + "req": { + "topic": "Reporte de accidentes formales", + "mode": "Roleplay", + "objective": + "Puedo reportar con precisión un accidente o peligro, describir su impacto y usar conectores para organizar la información.", + "media": "nan", + "activity_cefr_level": "B2", + "language_of_instructions": "es", + "target_language": "en", + "number_of_participants": 2, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "WpSo1d4XTqOEGjyDkJYlgjz7X9XY7nAHbOsh", + }, + { + "title": "Emergencia en el Edificio", + "learning_objective": + "Puedo dar y seguir instrucciones urgentes de forma clara y breve durante una emergencia.", + "instructions": + "Ustedes son un equipo de seguridad en un edificio de oficinas durante una emergencia. Cada uno tiene un rol específico y debe comunicarse mediante mensajes de voz claros y concisos.\n\n1. El Líder del Equipo: Inicia la actividad dando una instrucción urgente sobre la situación de emergencia. Por ejemplo: \"Attention everyone! There's a fire on the third floor. We need to evacuate immediately!\"\n\n2. El Coordinador de Evacuación: Responde al Líder y da instrucciones específicas sobre la ruta de evacuación. Por ejemplo: \"Understood! All personnel, use the east stairwell. Do not use the elevators!\"\n\n3. El Encargado de Comunicaciones: Confirma las instrucciones y añade información adicional o solicita aclaraciones si es necesario. Por ejemplo: \"Copy that. East stairwell for evacuation. Should I alert the fire department?\"\n\nContinúen la conversación, dando y siguiendo instrucciones urgentes relacionadas con la emergencia. Usen frases cortas y claras, y asegúrense de confirmar que han entendido las instrucciones de los demás.\n\nEjemplos de frases útiles en inglés:\n- \"Everyone, stay calm and follow instructions.\"\n- \"Is anyone injured? Report immediately.\"\n- \"Proceed to the nearest exit.\"\n- \"Do not stop to collect personal belongings.\"\n- \"Meet at the designated assembly point.\"\n- \"All clear\" or \"Situation under control\"", + "vocab": [ + {"lemma": "evacuate", "pos": "VERB"}, + {"lemma": "emergency", "pos": "NOUN"}, + {"lemma": "urgent", "pos": "ADJ"}, + {"lemma": "instruction", "pos": "NOUN"}, + {"lemma": "proceed", "pos": "VERB"}, + {"lemma": "immediately", "pos": "ADV"}, + {"lemma": "stairwell", "pos": "NOUN"}, + {"lemma": "alert", "pos": "VERB"}, + {"lemma": "assembly point", "pos": "NOUN"}, + {"lemma": "situation", "pos": "NOUN"}, + ], + "roles": { + "a2b5d634-c0e6-429e-83c0-efd6b1d47674": { + "name": "Líder del Equipo", + "id": "a2b5d634-c0e6-429e-83c0-efd6b1d47674", + }, + "df4b1480-7344-4abb-8a5e-4978bfb6a472": { + "name": "Coordinador de Evacuación", + "id": "df4b1480-7344-4abb-8a5e-4978bfb6a472", + }, + "a79cf4ec-6175-4293-be17-32ec3095826a": { + "name": "Encargado de Comunicaciones", + "id": "a79cf4ec-6175-4293-be17-32ec3095826a", + }, + }, + "req": { + "topic": "Instrucciones urgentes en equipo", + "mode": "Conversation", + "objective": + "Puedo dar y seguir instrucciones urgentes de forma clara y breve durante una emergencia.", + "media": "voice_messages", + "activity_cefr_level": "B2", + "language_of_instructions": "es", + "target_language": "en", + "number_of_participants": 3, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "mxF4eew7lya0PytnD3IQiKku1XtzNH71lku5", + }, + { + "title": "Simulación de Emergencia: Priorización de Acciones", + "learning_objective": + "Puedo priorizar y justificar una serie de pasos para mitigar riesgos y garantizar la seguridad.", + "instructions": + "Imaginen que son parte de un equipo de gestión de emergencias en una empresa. Se ha producido un incidente de seguridad y deben tomar decisiones rápidas para mitigar los riesgos.\n\n1. El Gerente de Seguridad presentará brevemente la situación de emergencia (por ejemplo, una fuga de gas en la oficina).\n\n2. El Coordinador de Evacuación propondrá 3-4 acciones prioritarias para abordar la emergencia.\n\n3. El Responsable de Comunicaciones evaluará cada acción propuesta, justificando su importancia o sugiriendo modificaciones.\n\n4. Juntos, deben llegar a un consenso sobre el orden de las acciones y explicar por qué han elegido ese orden.\n\nUtilicen frases como:\n\"I propose we... because...\"\n\"Our top priority should be... as it will...\"\n\"I agree/disagree with... because...\"\n\"We need to consider... before we...\"\n\nRecuerden justificar cada decisión y considerar las consecuencias de sus acciones.", + "vocab": [ + {"lemma": "prioritize", "pos": "VERB"}, + {"lemma": "mitigate", "pos": "VERB"}, + {"lemma": "risk", "pos": "NOUN"}, + {"lemma": "safety", "pos": "NOUN"}, + {"lemma": "emergency", "pos": "NOUN"}, + {"lemma": "incident", "pos": "NOUN"}, + {"lemma": "evacuation", "pos": "NOUN"}, + {"lemma": "consensus", "pos": "NOUN"}, + {"lemma": "justify", "pos": "VERB"}, + {"lemma": "consequence", "pos": "NOUN"}, + ], + "roles": { + "a7d56112-527e-4438-8d18-ecb66941f306": { + "name": "Gerente de Seguridad", + "id": "a7d56112-527e-4438-8d18-ecb66941f306", + }, + "31030937-d609-4c0f-a295-8a1c09f3b816": { + "name": "Coordinador de Evacuación", + "id": "31030937-d609-4c0f-a295-8a1c09f3b816", + }, + "efac637d-a210-4a41-ba2b-c8a25da99d9a": { + "name": "Responsable de Comunicaciones", + "id": "efac637d-a210-4a41-ba2b-c8a25da99d9a", + }, + }, + "req": { + "topic": "Priorización de acciones tras un incidente", + "mode": "Decision Making", + "objective": + "Puedo priorizar y justificar una serie de pasos para mitigar riesgos y garantizar la seguridad.", + "media": "nan", + "activity_cefr_level": "B2", + "language_of_instructions": "es", + "target_language": "en", + "number_of_participants": 3, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "CUPH3JJ7XyQJQYCyxRFzB9FQUXebuuiUUT66", + }, + { + "title": "Búsqueda del Tesoro de Seguridad", + "learning_objective": + "Puedo identificar y describir señales de emergencia y seguridad presentes en el almacén.", + "instructions": + "1. Cada participante asume su rol asignado.\n\n2. El Buscador de Señales debe encontrar y fotografiar 5 señales de seguridad diferentes en su entorno (casa, oficina, calle, etc.).\n\n3. Por cada señal, el Buscador de Señales debe:\n a) Enviar la foto al chat.\n b) Describir la señal en inglés (color, forma, símbolo).\n c) Explicar su significado y dónde se encuentra normalmente.\n\n4. El Experto en Seguridad debe:\n a) Confirmar si la descripción es correcta.\n b) Añadir información adicional sobre la señal o su uso.\n c) Hacer una pregunta relacionada con la señal o la seguridad en general.\n\n5. El Buscador de Señales debe responder a la pregunta del Experto en Seguridad.\n\nEjemplo de descripción:\n\"This is a red and white triangular sign with an exclamation mark. It means 'Danger' or 'Warning'. You usually find these signs near hazardous areas or equipment.\"\n\nRecuerden usar el vocabulario objetivo en sus descripciones y respuestas.", + "vocab": [ + {"lemma": "sign", "pos": "NOUN"}, + {"lemma": "safety", "pos": "NOUN"}, + {"lemma": "emergency", "pos": "NOUN"}, + {"lemma": "hazard", "pos": "NOUN"}, + {"lemma": "warning", "pos": "NOUN"}, + {"lemma": "caution", "pos": "NOUN"}, + {"lemma": "identify", "pos": "VERB"}, + {"lemma": "describe", "pos": "VERB"}, + {"lemma": "triangular", "pos": "ADJ"}, + {"lemma": "circular", "pos": "ADJ"}, + {"lemma": "rectangular", "pos": "ADJ"}, + {"lemma": "symbol", "pos": "NOUN"}, + {"lemma": "equipment", "pos": "NOUN"}, + {"lemma": "protective", "pos": "ADJ"}, + {"lemma": "mandatory", "pos": "ADJ"}, + ], + "roles": { + "dea6e386-13e7-43fd-9663-24a424332702": { + "name": "Buscador de Señales", + "id": "dea6e386-13e7-43fd-9663-24a424332702", + }, + "f7697321-51eb-41bc-a5ff-597462ce4ac5": { + "name": "Experto en Seguridad", + "id": "f7697321-51eb-41bc-a5ff-597462ce4ac5", + }, + }, + "req": { + "topic": "Reconocimiento de señales de seguridad", + "mode": "Scavenger Hunt", + "objective": + "Puedo identificar y describir señales de emergencia y seguridad presentes en el almacén.", + "media": "images", + "activity_cefr_level": "B2", + "language_of_instructions": "es", + "target_language": "en", + "number_of_participants": 2, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "2ny2ThkqcnNkJEuQYNqyDI7XUXMPSvu04rdo", + }, + { + "title": + "Debate: Protocolos Internos vs. Servicios de Emergencia", + "learning_objective": + "Puedo comparar y discutir diferencias entre los protocolos internos de la empresa y las instrucciones de los servicios de emergencia externos.", + "instructions": + "Ustedes son miembros de un comité de seguridad en una empresa multinacional. Van a debatir sobre las diferencias entre los protocolos internos de la empresa y las instrucciones de los servicios de emergencia externos.\n\n1. Cada uno de ustedes tiene un rol específico. Lean su rol y preparen sus argumentos.\n2. El Moderador iniciará el debate y dará la palabra a cada participante.\n3. Cada participante tendrá 2 minutos para presentar su posición inicial.\n4. Después de las presentaciones iniciales, el debate estará abierto para que todos participen.\n5. Usen frases como \"In my opinion...\", \"I agree/disagree because...\", \"From my perspective...\"\n6. El Moderador cerrará el debate después de 15 minutos y pedirá conclusiones finales.\n\nRecuerden usar el vocabulario clave en inglés durante el debate.", + "vocab": [ + {"lemma": "protocol", "pos": "NOUN"}, + {"lemma": "emergency", "pos": "NOUN"}, + {"lemma": "internal", "pos": "ADJ"}, + {"lemma": "external", "pos": "ADJ"}, + {"lemma": "safety", "pos": "NOUN"}, + {"lemma": "procedure", "pos": "NOUN"}, + {"lemma": "response", "pos": "NOUN"}, + {"lemma": "compare", "pos": "VERB"}, + {"lemma": "discuss", "pos": "VERB"}, + {"lemma": "difference", "pos": "NOUN"}, + ], + "roles": { + "3031c585-8d3f-44a9-8dc6-1e6d42a7aa8a": { + "name": "Moderador", + "id": "3031c585-8d3f-44a9-8dc6-1e6d42a7aa8a", + }, + "4486beb7-4248-4ef3-acf5-2daf99c33d04": { + "name": "Representante de Protocolos Internos", + "id": "4486beb7-4248-4ef3-acf5-2daf99c33d04", + }, + "5e10d599-2416-4ddd-b383-226d62f4627c": { + "name": "Representante de Servicios de Emergencia", + "id": "5e10d599-2416-4ddd-b383-226d62f4627c", + }, + "d322df27-fd9d-42b5-b122-84e6a762cc0c": { + "name": "Analista de Seguridad", + "id": "d322df27-fd9d-42b5-b122-84e6a762cc0c", + }, + }, + "req": { + "topic": "Protocolos internos vs. servicios de emergencia", + "mode": "Debate", + "objective": + "Puedo comparar y discutir diferencias entre los protocolos internos de la empresa y las instrucciones de los servicios de emergencia externos.", + "media": "nan", + "activity_cefr_level": "B2", + "language_of_instructions": "es", + "target_language": "en", + "number_of_participants": 4, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "9boWUimxrcMAKMhUxJH8PUofpUwHraPouUho", + } + ], + }, + { + "title": "Interacción con clientes y proveedores", + "description": + "Practicarás negotiating terms y prices politely, handling complaints con un enfoque diplomático y confirmando orders y delivery details de manera clara y profesional.", + "uuid": "976f7f27-02ce-47b5-9018-78d97b6b93bb", + "activities": [ + { + "title": "Negociación en la Sala de Juntas", + "learning_objective": + "Puedo negociar términos de pago y precios de manera cortés y profesional para llegar a acuerdos beneficiosos.", + "instructions": + "Ustedes son ejecutivos de dos empresas diferentes que se reúnen para negociar un contrato importante. \n\nEjecutivo A: Representas a una empresa de tecnología que ofrece servicios de software. Tu objetivo es vender tu nuevo sistema de gestión empresarial a un precio premium, destacando su calidad y características únicas.\n\nEjecutivo B: Representas a una empresa que necesita actualizar su sistema de gestión. Tu objetivo es obtener el mejor software posible a un precio razonable, negociando descuentos o términos de pago favorables.\n\nInicien la conversación con saludos formales y presentaciones. Luego, discutan los siguientes puntos:\n\n1. Características del software\n2. Precio inicial propuesto\n3. Posibles descuentos o paquetes\n4. Términos de pago (plazos, frecuencia)\n5. Soporte técnico y capacitación incluidos\n\nUtilicen frases corteses y profesionales como:\n- \"I understand your position, however...\"\n- \"Would you consider...?\"\n- \"What if we were to...?\"\n- \"I'm confident we can find a mutually beneficial solution.\"\n\nRecuerden mantener un tono respetuoso y buscar un acuerdo que beneficie a ambas partes. ¡Buena suerte en su negociación!", + "vocab": [ + {"lemma": "negotiate", "pos": "VERB"}, + {"lemma": "terms", "pos": "NOUN"}, + {"lemma": "price", "pos": "NOUN"}, + {"lemma": "discount", "pos": "NOUN"}, + {"lemma": "proposal", "pos": "NOUN"}, + {"lemma": "agreement", "pos": "NOUN"}, + {"lemma": "beneficial", "pos": "ADJ"}, + {"lemma": "compromise", "pos": "NOUN"}, + {"lemma": "flexible", "pos": "ADJ"}, + {"lemma": "consider", "pos": "VERB"}, + ], + "roles": { + "55936b2b-6658-4128-a609-822ca1725976": { + "name": "Ejecutivo A", + "id": "55936b2b-6658-4128-a609-822ca1725976", + }, + "9f2f5ddf-8176-4f4b-a6a3-213449fdbbb6": { + "name": "Ejecutivo B", + "id": "9f2f5ddf-8176-4f4b-a6a3-213449fdbbb6", + }, + }, + "req": { + "topic": "Negociación de términos y precios", + "mode": "Roleplay", + "objective": + "Puedo negociar términos de pago y precios de manera cortés y profesional para llegar a acuerdos beneficiosos.", + "media": "nan", + "activity_cefr_level": "B2", + "language_of_instructions": "es", + "target_language": "en", + "number_of_participants": 2, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "8wvge9rmOSodtef91jHrBJj3Wq26MUm44642", + }, + { + "title": "Manejando Quejas con Diplomacia", + "learning_objective": + "Puedo responder a quejas de clientes con un tono diplomático y proponer soluciones efectivas.", + "instructions": + "En esta actividad, practicarán cómo manejar quejas de clientes de manera diplomática en inglés. Un participante será el cliente insatisfecho y el otro será el representante de servicio al cliente. El cliente enviará un mensaje de voz expresando una queja, y el representante de servicio al cliente responderá con una solución diplomática. \n\nCliente: Envía un mensaje de voz expresando una queja sobre un producto o servicio. Sé específico sobre el problema.\n\nRepresentante de Servicio al Cliente: Escucha la queja y responde con un mensaje de voz. Asegúrate de:\n1. Disculparte por la inconveniencia\n2. Mostrar empatía\n3. Proponer una solución efectiva\n\nEjemplos de frases útiles en inglés:\n- \"I completely understand your frustration...\"\n- \"I apologize for the inconvenience this has caused you.\"\n- \"Let me propose a solution that I believe will address your concerns...\"\n- \"What we can do to resolve this issue is...\"\n\nRepitan el ejercicio con diferentes escenarios de quejas para practicar variedad de situaciones.", + "vocab": [ + {"lemma": "complaint", "pos": "NOUN"}, + {"lemma": "apologize", "pos": "VERB"}, + {"lemma": "empathize", "pos": "VERB"}, + {"lemma": "resolve", "pos": "VERB"}, + {"lemma": "inconvenience", "pos": "NOUN"}, + {"lemma": "propose", "pos": "VERB"}, + {"lemma": "solution", "pos": "NOUN"}, + {"lemma": "address", "pos": "VERB"}, + {"lemma": "concern", "pos": "NOUN"}, + {"lemma": "diplomatic", "pos": "ADJ"}, + ], + "roles": { + "7991979a-efac-4201-aa05-7bc7db966f6d": { + "name": "Cliente", + "id": "7991979a-efac-4201-aa05-7bc7db966f6d", + }, + "47356ead-ed6f-47a2-99f8-ddbf0a924cb9": { + "name": "Representante de Servicio al Cliente", + "id": "47356ead-ed6f-47a2-99f8-ddbf0a924cb9", + }, + }, + "req": { + "topic": "Atención diplomática a quejas de clientes", + "mode": "Conversation", + "objective": + "Puedo responder a quejas de clientes con un tono diplomático y proponer soluciones efectivas.", + "media": "voice_messages", + "activity_cefr_level": "B2", + "language_of_instructions": "es", + "target_language": "en", + "number_of_participants": 2, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "xQS2EcH8ku3BQblbpCDe2SrUdLBpU2eSlBoa", + }, + { + "title": "Coordinación de Pedidos en Equipo", + "learning_objective": + "Puedo verificar y confirmar todos los detalles de un pedido y coordinar plazos de entrega en equipo.", + "instructions": + "Ustedes son parte de un equipo de ventas en una empresa de muebles. Cada uno tiene un rol específico en el proceso de confirmación de pedidos y coordinación de entregas.\n\n1. El Representante de Ventas inicia la conversación presentando un nuevo pedido de un cliente importante. Debe proporcionar detalles como el tipo de muebles, cantidad y preferencias del cliente.\n\n2. El Coordinador de Inventario verifica la disponibilidad de los productos y sugiere posibles fechas de entrega basadas en el stock actual.\n\n3. El Gerente de Logística evalúa las rutas de entrega y los recursos disponibles para proponer el mejor plan de entrega.\n\nJuntos, deben tomar decisiones sobre:\n- Confirmar los detalles exactos del pedido\n- Establecer una fecha de entrega realista\n- Resolver cualquier conflicto o problema potencial\n\nUtilicen frases como:\n\"I can confirm that we have X units in stock.\"\n\"Based on our current delivery schedule, the earliest possible date would be...\"\n\"We need to consider the following factors before finalizing the delivery date...\"\n\nAsegúrense de verificar y confirmar todos los detalles importantes y llegar a un acuerdo final sobre el pedido y la entrega.", + "vocab": [ + {"lemma": "confirm", "pos": "VERB"}, + {"lemma": "coordinate", "pos": "VERB"}, + {"lemma": "delivery", "pos": "NOUN"}, + {"lemma": "schedule", "pos": "NOUN"}, + {"lemma": "inventory", "pos": "NOUN"}, + {"lemma": "logistics", "pos": "NOUN"}, + {"lemma": "availability", "pos": "NOUN"}, + {"lemma": "deadline", "pos": "NOUN"}, + {"lemma": "expedite", "pos": "VERB"}, + {"lemma": "prioritize", "pos": "VERB"}, + ], + "roles": { + "62f88131-8418-4ae5-a5d5-767dbe21111e": { + "name": "Representante de Ventas", + "id": "62f88131-8418-4ae5-a5d5-767dbe21111e", + }, + "312bae57-f424-4a2f-b064-f93f8083c467": { + "name": "Coordinador de Inventario", + "id": "312bae57-f424-4a2f-b064-f93f8083c467", + }, + "62ec4e6a-374f-4987-ab3d-98915e65dd31": { + "name": "Gerente de Logística", + "id": "62ec4e6a-374f-4987-ab3d-98915e65dd31", + }, + }, + "req": { + "topic": "Confirmación de pedidos y detalles de entrega", + "mode": "Decision Making", + "objective": + "Puedo verificar y confirmar todos los detalles de un pedido y coordinar plazos de entrega en equipo.", + "media": "nan", + "activity_cefr_level": "B2", + "language_of_instructions": "es", + "target_language": "en", + "number_of_participants": 3, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "1FEGV4MfxFYbkaW8TmE0NVUMHnzcp9whWWA2", + }, + { + "title": "Caza de Errores en Órdenes de Compra", + "learning_objective": + "Puedo localizar y corregir errores en documentos de pedido usando pistas visuales.", + "instructions": + "En esta actividad, ustedes participarán en una caza de errores en órdenes de compra. Cada uno tendrá un rol específico:\n\n1. El Detector de Errores: Busca y señala los errores en las imágenes de órdenes de compra.\n2. El Corrector: Propone correcciones para los errores encontrados.\n3. El Verificador: Confirma si las correcciones son adecuadas y explica por qué.\n\nPasos:\n1. El Detector de Errores recibirá una imagen de una orden de compra con errores. Debe identificar al menos 3 errores y describirlos en inglés. Por ejemplo: \"There's a typo in the product name\" o \"The quantity doesn't match the total price\".\n\n2. El Corrector revisará los errores identificados y propondrá correcciones en inglés. Por ejemplo: \"The product name should be 'Wireless Mouse' instead of 'Wirless Mouse'\" o \"The quantity should be 5 to match the total price of \$100\".\n\n3. El Verificador examinará las correcciones propuestas y confirmará si son correctas, explicando brevemente en inglés por qué. Si hay algún desacuerdo, deben discutirlo en inglés para llegar a un consenso.\n\n4. Repitan el proceso con nuevas imágenes de órdenes de compra, rotando los roles para cada nueva imagen.\n\nRecuerden usar frases en inglés como \"I noticed that...\", \"The correct version should be...\", \"I agree/disagree because...\".", + "vocab": [ + {"lemma": "purchase order", "pos": "NOUN"}, + {"lemma": "discrepancy", "pos": "NOUN"}, + {"lemma": "quantity", "pos": "NOUN"}, + {"lemma": "invoice", "pos": "NOUN"}, + {"lemma": "typo", "pos": "NOUN"}, + {"lemma": "verify", "pos": "VERB"}, + {"lemma": "correct", "pos": "VERB"}, + {"lemma": "identify", "pos": "VERB"}, + {"lemma": "accurate", "pos": "ADJ"}, + {"lemma": "erroneous", "pos": "ADJ"}, + ], + "roles": { + "5ed381a4-8bd3-4287-b175-335feef6b1ed": { + "name": "Detector de Errores", + "id": "5ed381a4-8bd3-4287-b175-335feef6b1ed", + }, + "43972f83-48fc-4549-9568-84a8d0f8cdd3": { + "name": "Corrector", + "id": "43972f83-48fc-4549-9568-84a8d0f8cdd3", + }, + "ebf8765b-f672-4519-9823-de57595bbe6f": { + "name": "Verificador", + "id": "ebf8765b-f672-4519-9823-de57595bbe6f", + }, + }, + "req": { + "topic": "Identificación de errores en órdenes de compra", + "mode": "Scavenger Hunt", + "objective": + "Puedo localizar y corregir errores en documentos de pedido usando pistas visuales.", + "media": "images", + "activity_cefr_level": "B2", + "language_of_instructions": "es", + "target_language": "en", + "number_of_participants": 3, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "QL4H08RaexRrFNLf38GWS06GRYcxOOnxnuaF", + }, + { + "title": "Debate sobre retrasos de envío", + "learning_objective": + "Puedo argumentar y defender diferentes puntos de vista sobre la responsabilidad en los retrasos de envío y proponer soluciones colaborativas.", + "instructions": + "1. Prepárate según tu rol (2 min): haz una lista de argumentos y datos clave.\n2. Debate por turnos (8 min): presenta tu punto de vista. Usa frases como “I believe that…”, “In my opinion…”, “We should…”.\n3. Propuesta colaborativa (5 min): juntos, decidan dos soluciones viables. Usa “How about we…”, “Let's try to…”.\nRecuerden respetar el turno de palabra y hacer preguntas al siguiente interlocutor.", + "vocab": [ + {"lemma": "accountability", "pos": "NOUN"}, + {"lemma": "compensation", "pos": "NOUN"}, + {"lemma": "expedite", "pos": "VERB"}, + {"lemma": "liability", "pos": "NOUN"}, + {"lemma": "logistics", "pos": "NOUN"}, + {"lemma": "negotiate", "pos": "VERB"}, + {"lemma": "stakeholder", "pos": "NOUN"}, + {"lemma": "guarantee", "pos": "VERB"}, + ], + "roles": { + "5f2842e1-0ea8-4c78-98c3-b0fc3f66551a": { + "name": "Logistics Manager", + "id": "5f2842e1-0ea8-4c78-98c3-b0fc3f66551a", + }, + "9046ce34-1dbc-4c60-a495-324ad297cc05": { + "name": "Customer Service Rep", + "id": "9046ce34-1dbc-4c60-a495-324ad297cc05", + }, + "b9b6e5b2-ed14-4ccc-b695-c147ee1e99a4": { + "name": "Client", + "id": "b9b6e5b2-ed14-4ccc-b695-c147ee1e99a4", + }, + "71eea50e-a3b4-4ce1-86b1-7d2569e5f851": { + "name": "Quality Assurance Officer", + "id": "71eea50e-a3b4-4ce1-86b1-7d2569e5f851", + }, + }, + "req": { + "topic": "Responsabilidad ante retrasos de envío", + "mode": "Debate", + "objective": + "Puedo argumentar y defender diferentes puntos de vista sobre la responsabilidad y proponer soluciones colaborativas.", + "media": "nan", + "activity_cefr_level": "B2", + "language_of_instructions": "es", + "target_language": "en", + "number_of_participants": 4, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "PXQaS73AucoWPuNKusdTLRiVlQH6bUi0hm8W", + } + ], + }, + { + "title": "Reuniones y presentaciones", + "description": + "Te prepararás para chair meetings o contribuir en ellas, presentar updates a la gestión y formular y responder questions con confianza.", + "uuid": "c3d4f637-616d-4887-a341-a59ca6d63308", + "activities": [ + { + "title": "Liderando la Reunión Semanal", + "learning_objective": + "Puedo liderar y estructurar una reunión de equipo de forma clara y eficaz.", + "instructions": + "En esta actividad, simularán una reunión semanal de equipo. Uno de ustedes será el líder de la reunión, otro será el encargado de tomar notas, y el tercero será un miembro del equipo que presenta un informe.\n\nLíder de la reunión: Tu tarea es abrir la reunión, establecer la agenda, moderar la discusión y cerrar la reunión. Utiliza frases como:\n- \"Let's get started with our weekly meeting.\"\n- \"First on the agenda is...\"\n- \"Does anyone have any questions or comments?\"\n- \"Let's move on to the next item.\"\n- \"To summarize our main points...\"\n\nEncargado de notas: Tu rol es tomar notas detalladas de la reunión. Asegúrate de capturar los puntos clave, decisiones y acciones a tomar. Puedes intervenir para clarificar información:\n- \"Could you please repeat that point?\"\n- \"Just to confirm, the deadline for this task is...\"\n\nMiembro del equipo: Prepara un breve informe sobre un proyecto o tarea reciente. Presenta tu informe cuando el líder te lo indique. Usa frases como:\n- \"I'd like to update the team on...\"\n- \"We've made progress in the following areas...\"\n- \"Some challenges we're facing include...\"\n\nMantengan la reunión estructurada y profesional, pero también amistosa. Asegúrense de practicar el vocabulario objetivo durante la actividad.", + "vocab": [ + {"lemma": "agenda", "pos": "NOUN"}, + {"lemma": "summarize", "pos": "VERB"}, + {"lemma": "clarify", "pos": "VERB"}, + {"lemma": "update", "pos": "VERB"}, + {"lemma": "progress", "pos": "NOUN"}, + {"lemma": "challenge", "pos": "NOUN"}, + {"lemma": "deadline", "pos": "NOUN"}, + {"lemma": "structure", "pos": "VERB"}, + {"lemma": "moderate", "pos": "VERB"}, + {"lemma": "efficient", "pos": "ADJ"}, + ], + "roles": { + "0a28c1d2-e8a0-4592-8131-9236ae87c7f5": { + "name": "Líder de la reunión", + "id": "0a28c1d2-e8a0-4592-8131-9236ae87c7f5", + }, + "b4650c90-df19-4d0b-945b-2a56bedbf88d": { + "name": "Encargado de notas", + "id": "b4650c90-df19-4d0b-945b-2a56bedbf88d", + }, + "2016208f-400a-44e8-b3a8-f80c333fdc2e": { + "name": "Miembro del equipo", + "id": "2016208f-400a-44e8-b3a8-f80c333fdc2e", + }, + }, + "req": { + "topic": "Chair de una reunión semanal", + "mode": "Roleplay", + "objective": + "Puedo liderar y estructurar una reunión de equipo de forma clara y eficaz.", + "media": "nan", + "activity_cefr_level": "B2", + "language_of_instructions": "es", + "target_language": "en", + "number_of_participants": 3, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "97E1AOnppu6uEjCCYoA7vH84hZKw1khQqquQ", + }, + { + "title": "Priorización de Agenda Colaborativa", + "learning_objective": + "Puedo elaborar y priorizar la agenda de una reunión mediante criterios compartidos.", + "instructions": + "1. Cada participante recibirá una imagen de una agenda de reunión con varios temas.\n\n2. Analicen la agenda y discutan en inglés la importancia de cada tema.\n\n3. Utilicen frases como:\n \"I think we should prioritize... because...\"\n \"In my opinion, ... is more urgent than...\"\n \"Can we move ... to a later time?\"\n\n4. Lleguen a un acuerdo sobre el orden final de los temas.\n\n5. El Coordinador creará una nueva imagen con la agenda priorizada y la compartirá.\n\n6. Expliquen brevemente en inglés por qué eligieron ese orden.", + "vocab": [ + {"lemma": "agenda", "pos": "NOUN"}, + {"lemma": "prioritize", "pos": "VERB"}, + {"lemma": "urgent", "pos": "ADJ"}, + {"lemma": "reschedule", "pos": "VERB"}, + {"lemma": "consensus", "pos": "NOUN"}, + {"lemma": "allocate", "pos": "VERB"}, + {"lemma": "timeframe", "pos": "NOUN"}, + {"lemma": "crucial", "pos": "ADJ"}, + ], + "roles": { + "69c472b1-1ec2-444d-9b76-1f2175b4b9da": { + "name": "Coordinador", + "id": "69c472b1-1ec2-444d-9b76-1f2175b4b9da", + }, + "1be464a9-6a80-467a-94ef-d1d67aa4e74a": { + "name": "Analista de Tiempo", + "id": "1be464a9-6a80-467a-94ef-d1d67aa4e74a", + }, + "e41a4bc2-516e-455e-ba66-5d569ea2b28d": { + "name": "Evaluador de Prioridades", + "id": "e41a4bc2-516e-455e-ba66-5d569ea2b28d", + }, + }, + "req": { + "topic": "Diseño y priorización de agenda", + "mode": "Decision Making", + "objective": + "Puedo elaborar y priorizar la agenda de una reunión mediante criterios compartidos.", + "media": "images", + "activity_cefr_level": "B2", + "language_of_instructions": "es", + "target_language": "en", + "number_of_participants": 3, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "62YLyxc87W8mk0WdTkpMc0DrabwWA9mx0Jl4", + }, + { + "title": "Sesión de preguntas y respuestas", + "learning_objective": + "Puedo formular y responder preguntas durante una presentación con fluidez.", + "instructions": + "Ustedes dos, en modo conversación, harán una simulación de Q&A por voice messages. El rol “Entrevistador” envía una pregunta en inglés (p. ej. “What inspired your research?”). El rol “Presentador” responde con un voice message claro y fluido. Hagan 3 rondas. Después de cada ronda, el entrevistador puede pedir clarificación con frases como “Could you clarify that?”", + "vocab": [ + {"lemma": "question", "pos": "NOUN"}, + {"lemma": "answer", "pos": "NOUN"}, + {"lemma": "clarify", "pos": "VERB"}, + {"lemma": "elaborate", "pos": "VERB"}, + {"lemma": "audience", "pos": "NOUN"}, + {"lemma": "presentation", "pos": "NOUN"}, + ], + "roles": { + "da4e1cff-02fb-4e15-8bd1-df1bd3ee171e": { + "name": "Entrevistador", + "id": "da4e1cff-02fb-4e15-8bd1-df1bd3ee171e", + }, + "c0c64779-22b3-43f9-a4dd-d156e39beda4": { + "name": "Presentador", + "id": "c0c64779-22b3-43f9-a4dd-d156e39beda4", + }, + }, + "req": { + "topic": "Sesión de preguntas y respuestas", + "mode": "Conversation", + "objective": + "Puedo formular y responder preguntas durante una presentación con fluidez.", + "media": "voice_messages", + "activity_cefr_level": "B2", + "language_of_instructions": "es", + "target_language": "en", + "number_of_participants": 2, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "KEZGuRuKooL2erlcoCfLnYjCKNrmSpxWwsKN", + }, + { + "title": "Adivina el Tema de la Reunión: 20 Preguntas", + "learning_objective": + "Puedo interpretar y adivinar el contenido de una reunión a partir de pistas en inglés.", + "instructions": + "1. Un participante (el \"Conocedor\") piensa en un tipo de reunión específica (por ejemplo, una reunión de negocios, una cita médica, una entrevista de trabajo, etc.).\n\n2. El otro participante (el \"Adivinador\") hace hasta 20 preguntas de sí o no en inglés para adivinar el tipo de reunión.\n\n3. El Conocedor solo puede responder \"Yes\", \"No\", o \"I'm not sure\" a las preguntas.\n\n4. El Adivinador debe usar el vocabulario y las frases proporcionadas para formular sus preguntas. Por ejemplo:\n - \"Is this meeting usually held in an office?\"\n - \"Does this meeting involve more than two people?\"\n - \"Is this a formal meeting?\"\n\n5. Si el Adivinador adivina correctamente antes de las 20 preguntas, gana. Si no, el Conocedor gana.\n\n6. Después de cada ronda, discutan en inglés qué pistas fueron más útiles para adivinar el tipo de reunión.\n\nRecuerden usar inglés durante todo el juego, excepto para aclaraciones necesarias.", + "vocab": [ + {"lemma": "agenda", "pos": "NOUN"}, + {"lemma": "attendee", "pos": "NOUN"}, + {"lemma": "minutes", "pos": "NOUN"}, + {"lemma": "presentation", "pos": "NOUN"}, + {"lemma": "schedule", "pos": "NOUN"}, + {"lemma": "venue", "pos": "NOUN"}, + {"lemma": "formal", "pos": "ADJ"}, + {"lemma": "casual", "pos": "ADJ"}, + {"lemma": "confidential", "pos": "ADJ"}, + {"lemma": "discuss", "pos": "VERB"}, + {"lemma": "participate", "pos": "VERB"}, + {"lemma": "collaborate", "pos": "VERB"}, + ], + "roles": { + "64e70673-ab81-460c-8188-742b626c1c24": { + "name": "Conocedor", + "id": "64e70673-ab81-460c-8188-742b626c1c24", + }, + "7f489f77-4cf4-413d-9bff-fb34d81cb39a": { + "name": "Adivinador", + "id": "7f489f77-4cf4-413d-9bff-fb34d81cb39a", + }, + }, + "req": { + "topic": "Adivina el tema de la reunión", + "mode": "20-Question Game", + "objective": + "Puedo interpretar y adivinar el contenido de una reunión a partir de pistas en inglés.", + "media": "nan", + "activity_cefr_level": "B2", + "language_of_instructions": "es", + "target_language": "en", + "number_of_participants": 2, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "N23dXwEKUayCzJONAanWAsGVHZI8svEBSt96", + }, + { + "title": "Debate de un plan presentado", + "learning_objective": + "Puedo argumentar a favor y en contra de propuestas presentadas en una reunión formal.", + "instructions": + "You have three roles: Moderador, Defensor del plan y Opositor al plan.\n1. Moderador (1 minuto): You introduce the proposal: \"The proposal is to…\".\n2. Defensor (2 minutos): You argue in favor with at least 2 puntos: use phrases like \"I support this proposal because…\" and \"One strength is…\".\n3. Opositor (2 minutos): You argue en contra con al menos 2 puntos: use phrases like \"I disagree because…\" and \"One concern is…\".\n4. Debate abierto (4 minutos): You respond entre ustedes: \"I agree because…\", \"I see your point, but…\", \"How would you address…?\"\n5. Moderador (1 minuto): You resumen the main arguments and suggest un compromiso: \"To conclude…\".", + "vocab": [ + {"lemma": "proposal", "pos": "NOUN"}, + {"lemma": "to support", "pos": "VERB"}, + {"lemma": "to oppose", "pos": "VERB"}, + {"lemma": "strength", "pos": "NOUN"}, + {"lemma": "concern", "pos": "NOUN"}, + {"lemma": "compromise", "pos": "NOUN"}, + ], + "roles": { + "db0d63ae-a5db-483e-b4ea-23f35db7553d": { + "name": "Moderador", + "id": "db0d63ae-a5db-483e-b4ea-23f35db7553d", + }, + "fb392c91-5204-4324-88cc-67f10e2aec2a": { + "name": "Defensor del plan", + "id": "fb392c91-5204-4324-88cc-67f10e2aec2a", + }, + "cb06f4f0-9977-41ac-be8f-a56ecb51347f": { + "name": "Opositor al plan", + "id": "cb06f4f0-9977-41ac-be8f-a56ecb51347f", + }, + }, + "req": { + "topic": "Debate de un plan presentado", + "mode": "Debate", + "objective": + "Puedo argumentar a favor y en contra de propuestas presentadas en una reunión formal.", + "media": "nan", + "activity_cefr_level": "B2", + "language_of_instructions": "es", + "target_language": "en", + "number_of_participants": 3, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "5JHT6S5QrWooM4cOMzrMesEEjd8iYEuavCBl", + } + ], + }, + { + "title": "Comunicación intercultural y en equipo", + "description": + "Desarrollarás habilidades para adaptar tu tono y estilo a proveedores internacionales, clarificar significados para evitar malentendidos y fomentar feedback y participación en tu equipo.", + "uuid": "510952ca-b0f6-42d6-b90e-79937180f397", + "activities": [ + { + "title": "Adaptación Cultural en el Mundo de los Negocios", + "learning_objective": + "Puedo adaptar mi registro y estilo de comunicación según el perfil cultural del interlocutor.", + "instructions": + "En esta actividad, cada uno de ustedes asumirá un rol diferente en una reunión de negocios internacional. Deben adaptar su estilo de comunicación según la cultura que representan.\n\n1. Ejecutivo estadounidense: Sé directo y orientado a resultados. Usa frases como \"Let's cut to the chase\" o \"What's the bottom line?\"\n\n2. Empresario japonés: Sé más indirecto y respetuoso de la jerarquía. Utiliza expresiones como \"If you don't mind, I would like to suggest...\" o \"With all due respect...\"\n\n3. Mediador intercultural: Tu papel es facilitar la comunicación entre los otros dos, explicando las diferencias culturales cuando sea necesario. Usa frases como \"In [culture], it's common to...\" o \"Perhaps we could find a middle ground...\"\n\nInicien una conversación sobre un posible acuerdo comercial, prestando atención a cómo adaptan su lenguaje y estilo según el perfil cultural de cada uno. Recuerden, el objetivo es comunicarse efectivamente respetando las diferencias culturales.", + "vocab": [ + {"lemma": "adapt", "pos": "VERB"}, + {"lemma": "cultural profile", "pos": "NOUN"}, + {"lemma": "communication style", "pos": "NOUN"}, + {"lemma": "intercultural", "pos": "ADJ"}, + {"lemma": "negotiate", "pos": "VERB"}, + {"lemma": "compromise", "pos": "NOUN"}, + {"lemma": "etiquette", "pos": "NOUN"}, + {"lemma": "hierarchy", "pos": "NOUN"}, + {"lemma": "direct", "pos": "ADJ"}, + {"lemma": "indirect", "pos": "ADJ"}, + ], + "roles": { + "2c97c9bb-897a-43ed-962f-2a43e2099a42": { + "name": "Ejecutivo estadounidense", + "id": "2c97c9bb-897a-43ed-962f-2a43e2099a42", + }, + "2d1ed6cc-9c7e-4b90-8af7-0df46689331e": { + "name": "Empresario japonés", + "id": "2d1ed6cc-9c7e-4b90-8af7-0df46689331e", + }, + "e9b37a1c-62de-4b4d-abb1-ea92bd073271": { + "name": "Mediador intercultural", + "id": "e9b37a1c-62de-4b4d-abb1-ea92bd073271", + }, + }, + "req": { + "topic": "Adaptación de registro según perfil cultural", + "mode": "Roleplay", + "objective": + "Puedo adaptar mi registro y estilo de comunicación según el perfil cultural del interlocutor.", + "media": "nan", + "activity_cefr_level": "B2", + "language_of_instructions": "es", + "target_language": "en", + "number_of_participants": 3, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "7ldATZ7oj5CYXeZEbCY4mHpAyowBdDfUHFyD", + }, + { + "title": "Aclarando malentendidos en una conversación telefónica", + "learning_objective": + "Puedo clarificar significados y resolver malentendidos solicitando y ofreciendo explicaciones claras.", + "instructions": + "En esta actividad, simularán una conversación telefónica donde uno de ustedes es un cliente que ha recibido un producto equivocado, y el otro es un representante de servicio al cliente. \n\n1. Cliente: Envía un mensaje de voz explicando el problema con el producto que recibiste. Usa frases como \"I think there's been a mistake\" o \"I'm not sure if I received the correct item\".\n\n2. Representante: Escucha el mensaje y responde pidiendo más detalles. Utiliza frases como \"Could you please clarify...\" o \"I'm not quite sure I understand. Can you explain...?\"\n\n3. Cliente: Proporciona la información solicitada, siendo lo más claro posible.\n\n4. Representante: Ofrece una solución al problema, asegurándote de que el cliente entienda completamente.\n\n5. Cliente: Si algo no está claro, pide más explicaciones. Si todo está claro, confirma que has entendido la solución.\n\nRecuerden usar estrategias de clarificación como parafrasear, pedir ejemplos o solicitar que se repita la información cuando sea necesario. Todos los mensajes deben ser de voz.", + "vocab": [ + {"lemma": "clarify", "pos": "VERB"}, + {"lemma": "misunderstanding", "pos": "NOUN"}, + {"lemma": "explain", "pos": "VERB"}, + {"lemma": "rephrase", "pos": "VERB"}, + {"lemma": "specify", "pos": "VERB"}, + {"lemma": "elaborate", "pos": "VERB"}, + {"lemma": "comprehend", "pos": "VERB"}, + {"lemma": "confusion", "pos": "NOUN"}, + ], + "roles": { + "244b7f3c-e925-45b5-a401-291f7f01d2dc": { + "name": "Cliente", + "id": "244b7f3c-e925-45b5-a401-291f7f01d2dc", + }, + "64b0b411-af87-4762-ac85-5828d5de7ae2": { + "name": "Representante de servicio al cliente", + "id": "64b0b411-af87-4762-ac85-5828d5de7ae2", + }, + }, + "req": { + "topic": "Estrategias de clarificación de significados", + "mode": "Conversation", + "objective": + "Puedo clarificar significados y resolver malentendidos solicitando y ofreciendo explicaciones claras.", + "media": "voice_messages", + "activity_cefr_level": "B2", + "language_of_instructions": "es", + "target_language": "en", + "number_of_participants": 2, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "2YSpL3ZBylh13dXnTs3gL9Dcf7wZ2MEbHrUa", + }, + { + "title": "Decidiendo mejoras en equipo", + "learning_objective": + "Puedo generar y priorizar ideas de mejora en equipo mediante un proceso de toma de decisiones colaborativo.", + "instructions": + "1. Moderador: Envía una imagen con tres escenarios de trabajo en equipo. You: \"Which scenario should we improve?\"\n2. Todos: Observa la imagen y #Sugiere una idea de mejora en inglés (\"I suggest we... \").\n3. Secretario: Anota todas las sugerencias.\n4. Cronometrador: Marca 5 minutos para la discusión.\n5. Todos: Da feedback usando frases como \"That’s a good point, but...\" o \"I agree because...\".\n6. Portavoz: Resume las ideas y guíate por \"Let’s prioritize these improvements...\" para llegar a un acuerdo.\n7. Moderador: Cierra con un decision statement en inglés: \"We decide to...\" y comparte la imagen final con las ideas ordenadas.", + "vocab": [ + {"lemma": "feedback", "pos": "NOUN"}, + {"lemma": "prioritize", "pos": "VERB"}, + {"lemma": "collaborative", "pos": "ADJ"}, + {"lemma": "improvement", "pos": "NOUN"}, + {"lemma": "suggestion", "pos": "NOUN"}, + {"lemma": "decision-making", "pos": "NOUN"}, + ], + "roles": { + "e51abc32-f283-46a9-a225-a8d9995a4d35": { + "name": "Moderador", + "id": "e51abc32-f283-46a9-a225-a8d9995a4d35", + }, + "570a2479-efa0-404a-9aa6-25b2cb4ce91e": { + "name": "Secretario", + "id": "570a2479-efa0-404a-9aa6-25b2cb4ce91e", + }, + "79d0eb6c-ffcc-40a1-a402-7d3351e0858f": { + "name": "Cronometrador", + "id": "79d0eb6c-ffcc-40a1-a402-7d3351e0858f", + }, + "eac0e39c-5d57-4cab-b7ff-257826fa575e": { + "name": "Portavoz", + "id": "eac0e39c-5d57-4cab-b7ff-257826fa575e", + }, + }, + "req": { + "topic": "Fomento de participación y feedback en equipo", + "mode": "Decision Making", + "objective": + "Puedo generar y priorizar ideas de mejora en equipo mediante un proceso de toma de decisiones colaborativo.", + "media": "images", + "activity_cefr_level": "B2", + "language_of_instructions": "es", + "target_language": "en", + "number_of_participants": 4, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "cbl3kep2pDN14PDsXkT2KUlR59AB39UHUKde", + }, + { + "title": "El Juego de las 20 Preguntas: Explorando Culturas", + "learning_objective": + "Puedo formular y responder preguntas efectivas para descubrir normas y costumbres de diferentes culturas.", + "instructions": + "En este juego de las 20 preguntas, explorarán normas y costumbres de diferentes culturas. \n\n1. El Anfitrión Cultural piensa en una norma o costumbre específica de una cultura particular sin revelarla.\n\n2. Los Exploradores Culturales harán preguntas de sí o no para adivinar la norma o costumbre. Pueden hacer hasta 20 preguntas en total.\n\n3. El Anfitrión Cultural responde solo con \"sí\" o \"no\".\n\n4. Los Exploradores Culturales trabajan juntos para adivinar la norma o costumbre antes de las 20 preguntas.\n\nEjemplos de preguntas en inglés:\n- \"Is this custom related to food?\"\n- \"Is this norm practiced in Asian countries?\"\n- \"Does this custom involve a specific gesture?\"\n\nRecuerden usar vocabulario variado y estructuras de preguntas efectivas para obtener la información necesaria.", + "vocab": [ + {"lemma": "custom", "pos": "NOUN"}, + {"lemma": "norm", "pos": "NOUN"}, + {"lemma": "culture", "pos": "NOUN"}, + {"lemma": "practice", "pos": "VERB"}, + {"lemma": "tradition", "pos": "NOUN"}, + {"lemma": "etiquette", "pos": "NOUN"}, + {"lemma": "gesture", "pos": "NOUN"}, + {"lemma": "ritual", "pos": "NOUN"}, + {"lemma": "taboo", "pos": "NOUN"}, + {"lemma": "diverse", "pos": "ADJ"}, + ], + "roles": { + "4a4b81a0-300d-4bea-b676-8997c63527bb": { + "name": "Anfitrión Cultural", + "id": "4a4b81a0-300d-4bea-b676-8997c63527bb", + }, + "c2da283d-585d-49f9-be46-ef7498db5abb": { + "name": "Explorador Cultural", + "id": "c2da283d-585d-49f9-be46-ef7498db5abb", + }, + "f4245ab4-6d49-442d-811f-fc34befd4699": { + "name": "Explorador Cultural", + "id": "f4245ab4-6d49-442d-811f-fc34befd4699", + }, + }, + "req": { + "topic": "Descubrimiento de normas culturales", + "mode": "20-Question Game", + "objective": + "Puedo formular y responder preguntas efectivas para descubrir normas y costumbres de diferentes culturas.", + "media": "nan", + "activity_cefr_level": "B2", + "language_of_instructions": "es", + "target_language": "en", + "number_of_participants": 3, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "a9F5VcSK6UZa4cNfTMxcqRRIl2Cw0BrFnk0f", + }, + { + "title": + "Debate: Adaptación intercultural vs procedimientos estándar", + "learning_objective": + "Puedo argumentar las ventajas y desventajas de adaptar el estilo de comunicación a distintas culturas frente a seguir procedimientos internos.", + "instructions": + "1. Tú, Moderador: inicia y cierra el debate. Da la palabra: “You have the floor.”\n2. Tú, Defensor adaptación intercultural: expón al menos 2 ventajas de adaptar el estilo de comunicación.\n3. Tú, Defensor procedimientos estándar: expón al menos 2 ventajas de seguir procedimientos internos.\n4. Tú, Cronometrista: controla 2 minutos por intervención con un cronómetro. Advierte con “30 seconds left.”\n5. Tú, Evaluador: toma notas de argumentos clave y al final comenta: “In my opinion…”\nUsa el vocabulario objetivo en inglés (por ejemplo: “I believe adapting shows flexibility.”). Sé claro y conciso.", + "vocab": [ + {"lemma": "adapt", "pos": "VERB"}, + {"lemma": "procedure", "pos": "NOUN"}, + {"lemma": "culture", "pos": "NOUN"}, + {"lemma": "flexibility", "pos": "NOUN"}, + {"lemma": "rigidity", "pos": "NOUN"}, + {"lemma": "advantage", "pos": "NOUN"}, + {"lemma": "disadvantage", "pos": "NOUN"}, + {"lemma": "negotiate", "pos": "VERB"}, + {"lemma": "context", "pos": "NOUN"}, + {"lemma": "protocol", "pos": "NOUN"}, + ], + "roles": { + "7372c013-559c-4004-aa82-6e328b98ff4b": { + "name": "Moderador", + "id": "7372c013-559c-4004-aa82-6e328b98ff4b", + }, + "ea50fbc3-f36b-4b36-9734-94d50b7633dd": { + "name": "Defensor adaptación intercultural", + "id": "ea50fbc3-f36b-4b36-9734-94d50b7633dd", + }, + "30c68d89-e0e3-49f1-a1d0-68f1a05cce5d": { + "name": "Defensor procedimientos estándar", + "id": "30c68d89-e0e3-49f1-a1d0-68f1a05cce5d", + }, + "f34ed32c-aba0-4d6f-ae81-8d8a1c101583": { + "name": "Cronometrista", + "id": "f34ed32c-aba0-4d6f-ae81-8d8a1c101583", + }, + "aa5372d2-4f21-4ab0-b320-b4e25da1baaf": { + "name": "Evaluador", + "id": "aa5372d2-4f21-4ab0-b320-b4e25da1baaf", + }, + }, + "req": { + "topic": "Adaptación intercultural vs procedimientos estándar", + "mode": "Debate", + "objective": + "Puedo argumentar las ventajas y desventajas de adaptar el estilo de comunicación a distintas culturas frente a seguir procedimientos internos.", + "media": "nan", + "activity_cefr_level": "B2", + "language_of_instructions": "es", + "target_language": "en", + "number_of_participants": 5, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "ddF5zAWfwKI5AEf0HmgnvM3PTI6bI1doiwmv", + } + ], + }, + { + "title": "Necesidades específicas de la industria", + "description": + "Abordaremos vocabulary y expresiones para trabajar con productos especializados (por ejemplo, gun holsters), terms legales o de compliance relevantes y el lenguaje para discutir quality control e inspection results.", + "uuid": "bc4273cc-b84c-4ade-aefe-5171ce59f68c", + "activities": [ + { + "title": "Inspección de Calidad en la Fábrica", + "learning_objective": + "Puedo describir y discutir hallazgos de control de calidad con un inspector usando terminología técnica adecuada.", + "instructions": + "Ustedes van a hacer un juego de roles sobre una inspección de calidad en una fábrica. Un participante será el gerente de control de calidad y el otro será el inspector externo.\n\nGerente de Control de Calidad: Usted debe presentar los resultados de las últimas pruebas de calidad, explicar los procedimientos utilizados y responder a las preguntas del inspector.\n\nInspector Externo: Usted debe hacer preguntas detalladas sobre los métodos de control de calidad, los resultados obtenidos y solicitar aclaraciones cuando sea necesario.\n\nUtilicen frases como:\n\"Our quality control measures include...\"\n\"The test results show that...\"\n\"Could you elaborate on the procedure for...?\"\n\"What actions are being taken to address...?\"\n\nAsegúrense de usar la terminología técnica apropiada y mantener un tono profesional durante toda la conversación.", + "vocab": [ + {"lemma": "quality control", "pos": "NOUN"}, + {"lemma": "inspection", "pos": "NOUN"}, + {"lemma": "procedure", "pos": "NOUN"}, + {"lemma": "compliance", "pos": "NOUN"}, + {"lemma": "defect", "pos": "NOUN"}, + {"lemma": "standard", "pos": "NOUN"}, + {"lemma": "measure", "pos": "VERB"}, + {"lemma": "analyze", "pos": "VERB"}, + {"lemma": "implement", "pos": "VERB"}, + {"lemma": "enhance", "pos": "VERB"}, + ], + "roles": { + "cd799d25-5c30-486c-977f-c6f8a2bc2e13": { + "name": "Gerente de Control de Calidad", + "id": "cd799d25-5c30-486c-977f-c6f8a2bc2e13", + }, + "41eefd27-70b8-4daf-a19d-5fed3dc16dc0": { + "name": "Inspector Externo", + "id": "41eefd27-70b8-4daf-a19d-5fed3dc16dc0", + }, + }, + "req": { + "topic": "Discusión de resultados de inspección de calidad", + "mode": "Roleplay", + "objective": + "Puedo describir y discutir hallazgos de control de calidad con un inspector usando terminología técnica adecuada.", + "media": "nan", + "activity_cefr_level": "B2", + "language_of_instructions": "es", + "target_language": "en", + "number_of_participants": 2, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "L06C6MeHHgOuXNoia9sI6etnG3X7Iix5nMzX", + }, + { + "title": "Auditoría Interna: Toma de Decisiones en Compliance", + "learning_objective": + "Puedo evaluar diferentes acciones y acordar medidas de cumplimiento legal óptimas para un caso de auditoría interna.", + "instructions": + "En esta actividad, simularán una reunión de auditoría interna para discutir y acordar medidas de cumplimiento legal. Cada uno de ustedes tendrá un rol específico en la auditoría.\n\n1. El Auditor Interno presentará un caso de incumplimiento potencial (por ejemplo, \"We've discovered inconsistencies in our financial reports\").\n\n2. El Asesor Legal sugerirá posibles medidas legales (por ejemplo, \"We should consider implementing stricter internal controls\").\n\n3. El Gerente de Compliance evaluará las sugerencias y propondrá un plan de acción (por ejemplo, \"I recommend we prioritize staff training on compliance procedures\").\n\nDiscutan el caso y las propuestas utilizando frases en inglés relacionadas con auditoría, cumplimiento y toma de decisiones. Lleguen a un acuerdo sobre las medidas óptimas a implementar.\n\nEnvíen sus respuestas como mensajes de voz en inglés, asegurándose de usar el vocabulario objetivo.\n\nRecuerden: sean claros, concisos y profesionales en sus comunicaciones.", + "vocab": [ + {"lemma": "compliance", "pos": "NOUN"}, + {"lemma": "audit", "pos": "NOUN"}, + {"lemma": "implement", "pos": "VERB"}, + {"lemma": "evaluate", "pos": "VERB"}, + {"lemma": "legal", "pos": "ADJ"}, + {"lemma": "measure", "pos": "NOUN"}, + {"lemma": "risk", "pos": "NOUN"}, + {"lemma": "regulation", "pos": "NOUN"}, + {"lemma": "procedure", "pos": "NOUN"}, + {"lemma": "decision-making", "pos": "NOUN"}, + ], + "roles": { + "d422744b-db4c-42a1-90ec-c23253bb2685": { + "name": "Auditor Interno", + "id": "d422744b-db4c-42a1-90ec-c23253bb2685", + }, + "ff0da778-6549-4bfa-8b9b-342e8be908f7": { + "name": "Asesor Legal", + "id": "ff0da778-6549-4bfa-8b9b-342e8be908f7", + }, + "7012595a-3194-42d3-abd6-b995b9e513a1": { + "name": "Gerente de Compliance", + "id": "7012595a-3194-42d3-abd6-b995b9e513a1", + }, + }, + "req": { + "topic": "Selección de medidas de compliance", + "mode": "Decision Making", + "objective": + "Puedo evaluar diferentes acciones y acordar medidas de cumplimiento legal óptimas para un caso de auditoría interna.", + "media": "voice_messages", + "activity_cefr_level": "B2", + "language_of_instructions": "es", + "target_language": "en", + "number_of_participants": 3, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "t6nfToahGwNWlNUKjf2fZaHiEuZVuMClUW7C", + }, + { + "title": "Negociación de Contratos Legales", + "learning_objective": + "Puedo explicar y negociar cláusulas de compliance y contratos con proveedores usando el vocabulario legal apropiado.", + "instructions": + "Ustedes son parte de una reunión de negociación de contratos. Cada uno tiene un rol específico en esta negociación. Deben discutir y negociar los términos del contrato, enfocándose en las cláusulas de compliance.\n\n1. El Representante de la Empresa debe explicar las necesidades y expectativas de la compañía.\n2. El Proveedor debe presentar sus servicios y tratar de negociar términos favorables.\n3. El Asesor Legal debe asegurarse de que todas las cláusulas de compliance se incluyan y se entiendan correctamente.\n\nUtilicen frases como:\n- \"We need to ensure that this clause covers...\"\n- \"Can you clarify the terms of...\"\n- \"I propose we modify this section to include...\"\n- \"From a legal standpoint, we must consider...\"\n\nRecuerden usar el vocabulario legal apropiado y mantener un tono profesional durante la negociación.", + "vocab": [ + {"lemma": "compliance", "pos": "NOUN"}, + {"lemma": "clause", "pos": "NOUN"}, + {"lemma": "contract", "pos": "NOUN"}, + {"lemma": "negotiate", "pos": "VERB"}, + {"lemma": "terms", "pos": "NOUN"}, + {"lemma": "legal", "pos": "ADJ"}, + {"lemma": "provision", "pos": "NOUN"}, + {"lemma": "agreement", "pos": "NOUN"}, + {"lemma": "stipulate", "pos": "VERB"}, + {"lemma": "liability", "pos": "NOUN"}, + ], + "roles": { + "fdaedfab-e6b7-4292-8b25-8ed832355c49": { + "name": "Representante de la Empresa", + "id": "fdaedfab-e6b7-4292-8b25-8ed832355c49", + }, + "ba97b352-4f79-4fa7-8fdd-00ad0bbde10a": { + "name": "Proveedor", + "id": "ba97b352-4f79-4fa7-8fdd-00ad0bbde10a", + }, + "21d8d3e4-87bf-4c4d-b84b-bb90777a384f": { + "name": "Asesor Legal", + "id": "21d8d3e4-87bf-4c4d-b84b-bb90777a384f", + }, + }, + "req": { + "topic": "Diálogo sobre términos legales", + "mode": "Conversation", + "objective": + "Puedo explicar y negociar cláusulas de compliance y contratos con proveedores usando el vocabulario legal apropiado.", + "media": "nan", + "activity_cefr_level": "B2", + "language_of_instructions": "es", + "target_language": "en", + "number_of_participants": 3, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "XUYxqoOFLnEJqxDWv6TbkafulFKWmrxMufqZ", + }, + { + "title": "Caza de Términos Clave de Calidad y Compliance", + "learning_objective": + "Puedo localizar y catalogar términos técnicos de control de calidad y regulación en documentos e imágenes de soporte.", + "instructions": + "1. Cada uno recibe un rol asignado. \n2. Buscad en documentos e imágenes las palabras clave en inglés. \n3. Cuando encontréis un término, gritad “Found!” y compartidlo con el grupo, por ejemplo: \"I found the word 'audit' in this paragraph.\" \n4. El Archivista anotará cada término en una lista con su definición breve. \n5. El Revisor verificará la lista y hará preguntas, por ejemplo: \"What does 'non-conformance' mean?\" \n6. En 15 minutos, completad la lista con al menos 8 términos.", + "vocab": [ + {"lemma": "compliance", "pos": "NOUN"}, + {"lemma": "audit", "pos": "NOUN"}, + {"lemma": "regulation", "pos": "NOUN"}, + {"lemma": "standard", "pos": "NOUN"}, + {"lemma": "benchmark", "pos": "NOUN"}, + {"lemma": "non-conformance", "pos": "NOUN"}, + {"lemma": "risk assessment", "pos": "NOUN"}, + {"lemma": "traceability", "pos": "NOUN"}, + ], + "roles": { + "a9370e26-6061-4c2f-a9be-19cfd71cd6c9": { + "name": "Inspector", + "id": "a9370e26-6061-4c2f-a9be-19cfd71cd6c9", + }, + "47311df9-c50f-45c9-9cbc-5c863952cff8": { + "name": "Analista", + "id": "47311df9-c50f-45c9-9cbc-5c863952cff8", + }, + "13170bef-af5b-40f2-9ffc-8a35b1cdbcde": { + "name": "Archivista", + "id": "13170bef-af5b-40f2-9ffc-8a35b1cdbcde", + }, + "06c73153-1f8c-4be2-89e8-577d9fedf897": { + "name": "Revisor", + "id": "06c73153-1f8c-4be2-89e8-577d9fedf897", + }, + }, + "req": { + "topic": "Búsqueda de términos clave de calidad y compliance", + "mode": "Scavenger Hunt", + "objective": + "Puedo localizar y catalogar términos técnicos de control de calidad y regulación en documentos e imágenes de soporte.", + "media": "nan", + "activity_cefr_level": "B2", + "language_of_instructions": "es", + "target_language": "en", + "number_of_participants": 4, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "hPz0jg9t7mhzYAUXBPgAedqrHsawYuCQCn5Y", + } + ], + } + ], + }, + { + "target_language": "Spanish", + "language_of_instructions": "English", + "cefr_level": "A1", + "title": "Portales Introductory Spanish Course Plan", + "description": + "This course plan aligns with the Higher Vista Learning Portales Introductory Spanish textbook for A1-level learners. You will work through each chapter (Capítulo) to acquire essential vocabulary, basic grammar structures, and practical communication skills.", + "uuid": "db853b7f-525d-4a1a-9108-7956b5d67717", + "topics": [ + { + "title": "Capítulo 1: ¡Hola! ¿Cómo estás?", + "description": + "You’ll learn basic greetings (hola, buenos días, buenas noches), farewells (adiós, hasta luego), and introductions. Practice asking and answering simple questions: ¿Cómo te llamas? Me llamo… Key structures: subject pronouns (yo, tú, él/ella), the verb llamarse (me llamo, te llamas).", + "uuid": "c6ae2941-8cba-4601-8e01-d5f30060a9c8", + "activity_ids": [], + "activities": [ + { + "title": "Name Tag Introductions", + "learning_objective": + "Students can ask and answer ¿Cómo te llamas? and introduce themselves using subject pronouns and the verb llamarse.", + "instructions": + "Each of you will create a name tag for yourself (you can draw it on paper and send a photo, or use an online tool). On your name tag, write your name and decorate it. Then, in Spanish, take turns introducing yourselves using 'Me llamo...' and asking '¿Cómo te llamas?'. Example:\n1. Send your name tag image.\n2. Say: 'Hola, me llamo [your name]. ¿Cómo te llamas?'\n3. The other person replies: 'Me llamo [their name]. ¡Mucho gusto!'\nPractice using the subject pronouns 'yo' and 'tú' if you wish.", + "vocab": [ + {"lemma": "llamarse", "pos": "VERB"}, + {"lemma": "¿Cómo te llamas?", "pos": "PHRASE"}, + {"lemma": "me llamo", "pos": "PHRASE"}, + {"lemma": "yo", "pos": "PRONOUN"}, + {"lemma": "tú", "pos": "PRONOUN"}, + {"lemma": "Hola", "pos": "INTERJECTION"}, + {"lemma": "Mucho gusto", "pos": "PHRASE"}, + ], + "roles": [ + { + "name": "Name Tag Creator 1", + "id": "a29c545d-ad37-44ef-a44e-614de33301e9", + }, + { + "name": "Name Tag Creator 2", + "id": "96cb83ca-7b14-4f50-96f7-1865e642768d", + } + ], + "req": { + "topic": "Introducing Yourself with Name Tags", + "mode": "Conversation", + "objective": + "Students can ask and answer ¿Cómo te llamas? and introduce themselves using subject pronouns and the verb llamarse.", + "media": "images", + "activity_cefr_level": "A1", + "language_of_instructions": "en", + "target_language": "es", + "number_of_participants": 2, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "sjgotSCqIabe79FRpVEq9E41SYHN4a6UCNlc", + }, + { + "title": "Find Someone Who…: Greetings Scavenger Hunt", + "learning_objective": + "Students can greet classmates and ask/respond ¿Cómo estás? to collect information from multiple peers.", + "instructions": + "Each of you will receive a list of descriptions (for example: someone who is happy, someone who is tired, etc.). Your goal is to walk around the room and use Spanish to greet your classmates and ask them '¿Cómo estás?'. Try to find someone who matches each description. When you find someone, write down their name and how they feel. Example phrases: '¡Hola! ¿Cómo estás?'; 'Estoy bien/cansado/a/feliz/triste.'", + "vocab": [ + {"lemma": "Hola", "pos": "INTJ"}, + {"lemma": "¿Cómo estás?", "pos": "PHRASE"}, + {"lemma": "Estoy...", "pos": "PHRASE"}, + {"lemma": "bien", "pos": "ADJ"}, + {"lemma": "cansado/cansada", "pos": "ADJ"}, + {"lemma": "feliz", "pos": "ADJ"}, + {"lemma": "triste", "pos": "ADJ"}, + ], + "roles": [ + { + "name": "Participant", + "id": "f6020a13-0669-43f0-b85b-1fa714efbe21", + }, + { + "name": "Participant", + "id": "581d5768-748a-4c83-bf03-0ed99e1f7637", + }, + { + "name": "Participant", + "id": "f9922017-591f-46a4-abb8-4b5d784f1177", + }, + { + "name": "Participant", + "id": "6fdf2504-8f01-4eb8-ba59-2574d0d22bc0", + } + ], + "req": { + "topic": "Find Someone Who…", + "mode": "Scavenger Hunt", + "objective": + "Students can greet classmates and ask/respond ¿Cómo estás? to collect information from multiple peers.", + "media": "nan", + "activity_cefr_level": "A1", + "language_of_instructions": "en", + "target_language": "es", + "number_of_participants": 4, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "PzQFGuH6QFuKqGIXPztevNIzBRBQuAjNlx1P", + }, + { + "title": "Guess the Classmate: 20-Question Voice Game", + "learning_objective": + "Students can formulate yes/no questions using subject pronouns to identify a mystery classmate.", + "instructions": + "You will play a guessing game in Spanish using voice messages. One of you is the Mystery Classmate. The others will ask yes/no questions in Spanish, using subject pronouns (tú, él, ella, etc.), to discover who the Mystery Classmate is. Use simple questions like: ¿Eres tú Juan? ¿Eres estudiante? ¿Tienes el pelo rubio? Record: Only yes/no questions are allowed! Take turns sending your questions as voice messages. The Mystery Classmate will reply with 'sí' or 'no' in a voice message. After each answer, you can guess again. You have up to 20 questions to find out who it is!", + "vocab": [ + {"lemma": "¿Eres tú...?", "pos": "PHRASE"}, + {"lemma": "sí", "pos": "ADV"}, + {"lemma": "no", "pos": "ADV"}, + {"lemma": "¿Tienes...?", "pos": "PHRASE"}, + {"lemma": "él", "pos": "PRON"}, + {"lemma": "ella", "pos": "PRON"}, + {"lemma": "tú", "pos": "PRON"}, + {"lemma": "estudiante", "pos": "NOUN"}, + {"lemma": "pelo", "pos": "NOUN"}, + {"lemma": "rubio", "pos": "ADJ"}, + {"lemma": "moreno", "pos": "ADJ"}, + {"lemma": "alto", "pos": "ADJ"}, + {"lemma": "bajo", "pos": "ADJ"}, + ], + "roles": [ + { + "name": "Mystery Classmate", + "id": "0879eac8-bf98-460e-b70d-fab8f2769f83", + }, + { + "name": "Questioner 1", + "id": "869bcf0d-e2f3-462b-a037-7151e155d01b", + }, + { + "name": "Questioner 2", + "id": "9e3a3343-e2d0-4c26-8243-ae602594ae7f", + }, + { + "name": "Questioner 3", + "id": "dd0a1d67-1817-493a-bb98-476fef833a7d", + } + ], + "req": { + "topic": "Guess the Classmate", + "mode": "20-Question Game", + "objective": + "Students can formulate yes/no questions using subject pronouns to identify a mystery classmate.", + "media": "voice_messages", + "activity_cefr_level": "A1", + "language_of_instructions": "en", + "target_language": "es", + "number_of_participants": 4, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "FPhz4IyhsvLAioKZWHCIjqPWwzsJKPHqf8Fq", + }, + { + "title": "First Meeting Roleplay: Greetings and Introductions", + "learning_objective": + "Students can perform a roleplay exchanging greetings, introductions, and farewells in a short dialogue.", + "instructions": + "Work in pairs. Each of you will play a different role. Use Spanish to greet each other, introduce yourselves (say your name and ask the other person's name), say how you are, and say goodbye. Use simple phrases, for example: \"Hola, ¿cómo te llamas?\", \"Me llamo Ana. ¿Y tú?\", \"¿Cómo estás?\", \"Estoy bien, gracias.\", \"Adiós\". Try to have a short conversation using these phrases.", + "vocab": [ + {"lemma": "hola", "pos": "INTJ"}, + {"lemma": "¿cómo te llamas?", "pos": "PHRASE"}, + {"lemma": "me llamo...", "pos": "PHRASE"}, + {"lemma": "¿y tú?", "pos": "PHRASE"}, + {"lemma": "¿cómo estás?", "pos": "PHRASE"}, + {"lemma": "estoy bien", "pos": "PHRASE"}, + {"lemma": "gracias", "pos": "INTJ"}, + {"lemma": "adiós", "pos": "INTJ"}, + ], + "roles": [ + { + "name": "Person A (introduces themselves first)", + "id": "b88958a5-9222-486d-86fa-762802a66922", + }, + { + "name": "Person B (responds and asks back)", + "id": "e05ae6b6-1d41-4ade-8c83-38d069e1ebe9", + } + ], + "req": { + "topic": "First Meeting Dialogue", + "mode": "Roleplay", + "objective": + "Students can perform a roleplay exchanging greetings, introductions, and farewells in a short dialogue.", + "media": "nan", + "activity_cefr_level": "A1", + "language_of_instructions": "en", + "target_language": "es", + "number_of_participants": 2, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "sHfdWiycVY4I1gs2MnEEAsjATbotA4HvQP2f", + }, + { + "title": "Greeting Expert Panel: Video Decision Makers", + "learning_objective": + "Students can choose and justify the appropriate greeting (hola, buenos días, buenas noches) for different video scenarios.", + "instructions": + "You will each watch a short video showing a different situation. Your role is to decide which Spanish greeting is correct for your video (hola, buenos días, buenas noches) and explain why, using simple Spanish. For example: 'Digo buenos días porque es la mañana.' After you share your greeting and reason, the group can discuss if they agree. Use the phrases: 'Digo...', 'porque...', 'Estoy de acuerdo/no estoy de acuerdo.'", + "vocab": [ + {"lemma": "hola", "pos": "INTJ"}, + {"lemma": "buenos días", "pos": "PHRASE"}, + {"lemma": "buenas noches", "pos": "PHRASE"}, + {"lemma": "porque", "pos": "CONJ"}, + {"lemma": "mañana", "pos": "NOUN"}, + {"lemma": "tarde", "pos": "NOUN"}, + {"lemma": "noche", "pos": "NOUN"}, + ], + "roles": [ + { + "name": "Video 1 Decider", + "id": "e4421172-f732-45ed-9540-c378fec4b0db", + }, + { + "name": "Video 2 Decider", + "id": "7893aea3-527d-4416-a58b-5c59c03966bb", + }, + { + "name": "Video 3 Decider", + "id": "411c10c8-e6b8-490c-8364-9117c7638450", + } + ], + "req": { + "topic": "Greeting Expert Panel", + "mode": "Decision Making", + "objective": + "Students can choose and justify the appropriate greeting (hola, buenos días, buenas noches) for different video scenarios.", + "media": "videos", + "activity_cefr_level": "A1", + "language_of_instructions": "en", + "target_language": "es", + "number_of_participants": 3, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "pWm9cOguieo3k8m9jTImSINCfdJC4BHtoAja", + } + ], + }, + { + "title": "Capítulo 2: Mi familia y mis amigos", + "description": + "Talk about family members (la madre, el padre, el hermano), friends (el amigo, la amiga) and relationships. Learn possessive adjectives (mi, tu, su) and basic descriptions: Mi hermana es alta. Key vocabulary: colores, rasgos físicos (ojos, pelo).", + "uuid": "d735e808-1798-4a7c-a627-990eaf7e91dd", + "activity_ids": [], + "activities": [ + { + "title": "Meet My Family!", + "learning_objective": + "I can introduce my family members and ask about my friend’s family using possessive adjectives (mi, tu, su).", + "instructions": + "Roleplay meeting a friend and talking about your families. \n\nRole 1: You introduce your family members using 'mi' (my). Example: \"Mi madre se llama Ana. Mi padre es profesor.\"\nRole 2: You ask about your friend's family using 'tu' (your) and answer questions about your own family using 'mi'. Example: \"¿Cómo se llama tu madre? ¿Cuántos hermanos tienes?\" \n\nUse phrases like: \"Mi hermano se llama...\", \"¿Tienes una hermana?\", \"¿Cómo es tu familia?\". \nTake turns asking and answering questions about your families.", + "vocab": [ + {"lemma": "mi", "pos": "ADJ"}, + {"lemma": "tu", "pos": "ADJ"}, + {"lemma": "su", "pos": "ADJ"}, + {"lemma": "madre", "pos": "NOUN"}, + {"lemma": "padre", "pos": "NOUN"}, + {"lemma": "hermano", "pos": "NOUN"}, + {"lemma": "hermana", "pos": "NOUN"}, + {"lemma": "familia", "pos": "NOUN"}, + {"lemma": "llamarse", "pos": "VERB"}, + {"lemma": "tener", "pos": "VERB"}, + ], + "roles": [ + { + "name": "Family Introducer", + "id": "eafc99b3-9b15-42b0-a7a3-bf9c85f41b3a", + }, + { + "name": "Family Questioner", + "id": "bb63c3a6-abd9-4aff-b49b-5ccca700b74b", + } + ], + "req": { + "topic": "Meeting a friend’s family", + "mode": "Roleplay", + "objective": + "I can introduce my family members and ask about my friend’s family using possessive adjectives (mi, tu, su).", + "media": "nan", + "activity_cefr_level": "A1", + "language_of_instructions": "en", + "target_language": "es", + "number_of_participants": 2, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "WHbSEAFoxFBwQC6UeLOzMsZaJLGaIGilCEZl", + }, + { + "title": "20-Question: Guess the Family Member", + "learning_objective": + "I can ask yes/no questions and use physical trait vocabulary to identify a specified family member.", + "instructions": + "You will play a voice message game in Spanish!\n\nRole 1 (Questioner): Think of a family member (e.g., mamá, abuelo) and do not say who it is. The Questioner will ask yes/no questions in Spanish about the family member’s physical traits (for example: ¿Tiene el pelo corto? ¿Es alto?). Send each question as a voice message.\n\nRole 2 (Responder): Listen to the questions and reply with 'sí' or 'no' in a voice message. You can also repeat the question before answering (for example: ¿Tiene gafas? No.)\n\nThe Questioner has up to 20 questions to guess which family member the Responder is thinking of. Use Spanish vocabulary for family members and physical traits. Example questions:\n- ¿Es una mujer?\n- ¿Tiene el pelo rubio?\n- ¿Lleva gafas?\n\nTry to guess correctly before 20 questions!", + "vocab": [ + {"lemma": "madre", "pos": "NOUN"}, + {"lemma": "padre", "pos": "NOUN"}, + {"lemma": "hermano", "pos": "NOUN"}, + {"lemma": "hermana", "pos": "NOUN"}, + {"lemma": "abuelo", "pos": "NOUN"}, + {"lemma": "abuela", "pos": "NOUN"}, + {"lemma": "tío", "pos": "NOUN"}, + {"lemma": "tía", "pos": "NOUN"}, + {"lemma": "alto", "pos": "ADJ"}, + {"lemma": "bajo", "pos": "ADJ"}, + {"lemma": "pelo corto", "pos": "NOUN"}, + {"lemma": "pelo largo", "pos": "NOUN"}, + {"lemma": "pelo rubio", "pos": "NOUN"}, + {"lemma": "pelo moreno", "pos": "NOUN"}, + {"lemma": "gafas", "pos": "NOUN"}, + {"lemma": "barba", "pos": "NOUN"}, + {"lemma": "bigote", "pos": "NOUN"}, + ], + "roles": [ + { + "name": "Questioner", + "id": "c0f8d6d2-c021-442e-a57c-60c3524c8790", + }, + { + "name": "Responder", + "id": "30052bbc-95c2-402d-af19-b74317e48ef1", + } + ], + "req": { + "topic": "Guess the Family Member", + "mode": "20-Question Game", + "objective": + "I can ask yes/no questions and use physical trait vocabulary to identify a specified family member.", + "media": "voice_messages", + "activity_cefr_level": "A1", + "language_of_instructions": "en", + "target_language": "es", + "number_of_participants": 2, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "HeAusvniLwnmOnIDGbK6Hcv7EqXqeWHGoMAK", + }, + { + "title": "Family Photo Detective", + "learning_objective": + "I can describe people in images, stating their family roles, hair color, eye color, and physical traits.", + "instructions": + "You will receive a photo of a family. Each of you has a different role:\n\n1. The Describer: Describe one person in the photo using Spanish. Include their family role (e.g., madre, hermano), hair color, eye color, and one physical trait. Use simple sentences. Example: \"Es mi madre. Tiene el pelo rubio y los ojos azules. Es alta.\"\n\n2. The Questioner: Ask the Describer about another person in the photo, using Spanish. Example: \"¿Cómo es el padre? ¿Tiene el pelo corto o largo?\"\n\n3. The Checker: Listen and make sure the answers include family role, hair color, eye color, and a physical trait. If something is missing, ask: \"¿Y los ojos?\" or \"¿Y el pelo?\"\n\nTake turns so each person tries each role with a new photo. Use the example phrases to help you.", + "vocab": [ + {"lemma": "madre", "pos": "NOUN"}, + {"lemma": "padre", "pos": "NOUN"}, + {"lemma": "hermano", "pos": "NOUN"}, + {"lemma": "hermana", "pos": "NOUN"}, + {"lemma": "abuelo", "pos": "NOUN"}, + {"lemma": "abuela", "pos": "NOUN"}, + {"lemma": "pelo", "pos": "NOUN"}, + {"lemma": "ojos", "pos": "NOUN"}, + {"lemma": "rubio", "pos": "ADJ"}, + {"lemma": "moreno", "pos": "ADJ"}, + {"lemma": "castaño", "pos": "ADJ"}, + {"lemma": "negro", "pos": "ADJ"}, + {"lemma": "azul", "pos": "ADJ"}, + {"lemma": "verde", "pos": "ADJ"}, + {"lemma": "grande", "pos": "ADJ"}, + {"lemma": "pequeño", "pos": "ADJ"}, + {"lemma": "alto", "pos": "ADJ"}, + {"lemma": "bajo", "pos": "ADJ"}, + {"lemma": "delgado", "pos": "ADJ"}, + {"lemma": "gordo", "pos": "ADJ"}, + ], + "roles": [ + { + "name": "Describer", + "id": "2175d915-5248-4deb-b515-cb8d4a0935c6", + }, + { + "name": "Questioner", + "id": "9eda103f-f20e-43b9-b309-e38da9af4d2f", + }, + { + "name": "Checker", + "id": "8be7b5d1-c1ef-44d2-8166-9c5947a52b2d", + } + ], + "req": { + "topic": "Describing Photos of Families", + "mode": "Conversation", + "objective": + "I can describe people in images, stating their family roles, hair color, eye color, and physical traits.", + "media": "images", + "activity_cefr_level": "A1", + "language_of_instructions": "en", + "target_language": "es", + "number_of_participants": 3, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "eYKnUovlUA8pX5hfsnzeSxWPD3tVhJCDaS0K", + }, + { + "title": "Let's Plan a Family Reunion!", + "learning_objective": + "I can collaborate to plan a family reunion, assigning tasks and roles using possessive adjectives.", + "instructions": + "Work together to make a plan for a family reunion. Each of you has a special role. Use Spanish possessive adjectives (mi, tu, su, nuestro/a) to talk about family members and tasks. Decide who will do each job and say it in Spanish. Example: \"Yo preparo la comida para mi familia.\" or \"Tú compras los regalos para tu familia.\" Write your plan together!", + "vocab": [ + {"lemma": "mi", "pos": "ADJ"}, + {"lemma": "tu", "pos": "ADJ"}, + {"lemma": "su", "pos": "ADJ"}, + {"lemma": "nuestro/a", "pos": "ADJ"}, + {"lemma": "familia", "pos": "NOUN"}, + {"lemma": "hermano/a", "pos": "NOUN"}, + {"lemma": "madre", "pos": "NOUN"}, + {"lemma": "padre", "pos": "NOUN"}, + {"lemma": "comida", "pos": "NOUN"}, + {"lemma": "regalos", "pos": "NOUN"}, + {"lemma": "decoraciones", "pos": "NOUN"}, + {"lemma": "invitar", "pos": "VERB"}, + {"lemma": "preparar", "pos": "VERB"}, + {"lemma": "comprar", "pos": "VERB"}, + ], + "roles": [ + { + "name": "Organizer", + "id": "5941462e-d22b-4ad1-b8a6-5d8d575e3962", + }, + {"name": "Cook", "id": "e9673072-7d42-4e4b-818a-6a6095f5f7f5"}, + { + "name": "Decorator", + "id": "fe2bd978-8b49-476c-986a-1b657a2a4740", + } + ], + "req": { + "topic": "Planning a Family Reunion", + "mode": "Decision Making", + "objective": + "I can collaborate to plan a family reunion, assigning tasks and roles using possessive adjectives.", + "media": "nan", + "activity_cefr_level": "A1", + "language_of_instructions": "en", + "target_language": "es", + "number_of_participants": 3, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "J2aIMHMV4to5bIqNJIQkzTfIUMwM94f50cG8", + }, + { + "title": "Family & Friends Scavenger Hunt", + "learning_objective": + "I can ask classmates specific questions about their family members and friends to find peers matching given descriptions.", + "instructions": + "You will each receive a description of a family member or friend (for example: 'una hermana mayor', 'un amigo simpático', 'una abuela que vive cerca'). Your goal is to find a classmate who has a family member or friend that matches your description. Use Spanish to ask questions like: '¿Tienes una hermana mayor?', '¿Tienes un amigo simpático?', or '¿Tienes una abuela que vive cerca?'. When you find a match, write down the classmate’s name and the person they told you about. Example phrases: '¿Tienes...?', 'Sí, tengo...', 'No, no tengo...'.", + "vocab": [ + {"lemma": "hermano/hermana", "pos": "NOUN"}, + {"lemma": "amigo/amiga", "pos": "NOUN"}, + {"lemma": "padre/madre", "pos": "NOUN"}, + {"lemma": "abuelo/abuela", "pos": "NOUN"}, + {"lemma": "mayor", "pos": "ADJ"}, + {"lemma": "menor", "pos": "ADJ"}, + {"lemma": "simpático/simpática", "pos": "ADJ"}, + {"lemma": "vivir", "pos": "VERB"}, + {"lemma": "cerca/lejos", "pos": "ADV"}, + ], + "roles": [ + { + "name": "Questioner 1", + "id": "39126ab9-f148-4924-8001-d356892ea6e3", + }, + { + "name": "Questioner 2", + "id": "e4f9de15-ad41-4f7c-bde5-67c518c3fec6", + }, + { + "name": "Questioner 3", + "id": "4005bbeb-f452-45d8-901b-d5a4b3abf42d", + } + ], + "req": { + "topic": "Family & Friends Scavenger Hunt", + "mode": "Scavenger Hunt", + "objective": + "I can ask classmates specific questions about their family members and friends to find peers matching given descriptions.", + "media": "nan", + "activity_cefr_level": "A1", + "language_of_instructions": "en", + "target_language": "es", + "number_of_participants": 3, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "gynyOF5myAhYb8UoE89fGz0qvN27utMKN1LV", + } + ], + }, + { + "title": "Capítulo 3: En la escuela", + "description": + "Focus on classroom vocabulary (el aula, el libro, la mochila), talking about your schedule (la materia, el horario) and using the present tense of regular –ar verbs (estudiar, trabajar). Practice asking and answering ¿Qué clase tienes? Tengo clase de matemáticas.", + "uuid": "a337a44a-2d8e-49cd-b2d5-3280762fbbec", + "activity_ids": [], + "activities": [ + { + "title": "Classroom Objects Scavenger Hunt", + "learning_objective": + "Can identify and name common classroom objects in Spanish by finding and describing them in context.", + "instructions": + "Each of you will have a different role. The Questioner will name or describe a classroom object in Spanish (for example: \"¿Dónde está el lápiz?\" or \"Busca una silla.\"). The Finder will look for the object in your classroom or surroundings, take a photo of it, and send the image to the group chat. The Describer will write a short sentence in Spanish describing the object in the photo (for example: \"Es una mesa marrón.\"). Rotate so each person tries each role in a new round. Use the vocab list for help. ¡Diviértanse!", + "vocab": [ + {"lemma": "lápiz", "pos": "NOUN"}, + {"lemma": "silla", "pos": "NOUN"}, + {"lemma": "mesa", "pos": "NOUN"}, + {"lemma": "libro", "pos": "NOUN"}, + {"lemma": "mochila", "pos": "NOUN"}, + {"lemma": "cuaderno", "pos": "NOUN"}, + {"lemma": "bolígrafo", "pos": "NOUN"}, + {"lemma": "pizarra", "pos": "NOUN"}, + {"lemma": "regla", "pos": "NOUN"}, + {"lemma": "goma", "pos": "NOUN"}, + ], + "roles": [ + { + "name": "Questioner", + "id": "1202d087-6344-4553-b0ce-765c24282831", + }, + { + "name": "Finder", + "id": "c28af6f8-fc87-48a1-88a5-a2daf067100c", + }, + { + "name": "Describer", + "id": "05de7a0c-e66e-4733-89ed-72fab853838e", + } + ], + "req": { + "topic": "Encuentra los objetos del aula", + "mode": "Scavenger Hunt", + "objective": + "Can identify and name common classroom objects in Spanish by finding and describing them in context.", + "media": "images", + "activity_cefr_level": "A1", + "language_of_instructions": "en", + "target_language": "es", + "number_of_participants": 3, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "UES8E78di1kHv9ZgpYQFvwMO037dKL803vPB", + }, + { + "title": "Guess the School Supply: 20-Question Game", + "learning_objective": + "Can ask and answer yes/no questions to guess different school supplies.", + "instructions": + "One person thinks of a school supply (material escolar) from the list. The other person asks up to 20 yes/no questions in Spanish to guess what it is. Use questions like: ¿Es grande? (Is it big?), ¿Se usa para escribir? (Is it used for writing?), ¿Es de papel? (Is it made of paper?), etc. The Responder answers only sí or no. Try to guess the school supply before reaching 20 questions! Example: ¿Es un lápiz?", + "vocab": [ + {"lemma": "lápiz", "pos": "NOUN"}, + {"lemma": "bolígrafo", "pos": "NOUN"}, + {"lemma": "cuaderno", "pos": "NOUN"}, + {"lemma": "goma", "pos": "NOUN"}, + {"lemma": "regla", "pos": "NOUN"}, + {"lemma": "mochila", "pos": "NOUN"}, + {"lemma": "libro", "pos": "NOUN"}, + {"lemma": "estuche", "pos": "NOUN"}, + {"lemma": "tijeras", "pos": "NOUN"}, + {"lemma": "pegamento", "pos": "NOUN"}, + ], + "roles": [ + { + "name": "Questioner", + "id": "5ef51dec-cbad-4dac-9f79-201c5906e52e", + }, + { + "name": "Responder", + "id": "ee198e7c-f1f1-4816-ac1d-b04c75d3f3dc", + } + ], + "req": { + "topic": "Adivina el material escolar", + "mode": "20-Question Game", + "objective": + "Can ask and answer yes/no questions to guess different school supplies.", + "media": "nan", + "activity_cefr_level": "A1", + "language_of_instructions": "en", + "target_language": "es", + "number_of_participants": 2, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "aLg7ZaiTzZOcCM50rKmai5UW4XyBOmIB3Kbb", + }, + { + "title": "Class Schedule Interview", + "learning_objective": + "Can ask '¿Qué clase tienes?' and respond using 'Tengo clase de…' to describe your daily schedule with –ar verbs.", + "instructions": + "One of you is the Interviewer and the other is the Student. The Interviewer asks: '¿Qué clase tienes?' The Student responds with: 'Tengo clase de [subject],' using –ar verbs and the provided vocabulary. You can add the time or day if you want. Example: Interviewer: ¿Qué clase tienes? Student: Tengo clase de matemáticas. Estudio a las ocho. Repeat for at least three different classes.", + "vocab": [ + {"lemma": "clase", "pos": "NOUN"}, + {"lemma": "tengo", "pos": "VERB"}, + {"lemma": "estudio", "pos": "VERB"}, + {"lemma": "hablo", "pos": "VERB"}, + {"lemma": "escucho", "pos": "VERB"}, + {"lemma": "matemáticas", "pos": "NOUN"}, + {"lemma": "español", "pos": "NOUN"}, + {"lemma": "ciencias", "pos": "NOUN"}, + {"lemma": "arte", "pos": "NOUN"}, + {"lemma": "historia", "pos": "NOUN"}, + ], + "roles": [ + { + "name": "Interviewer", + "id": "7f6a9207-0dbb-4a3b-a49b-b49516aa9a1d", + }, + { + "name": "Student", + "id": "65edcaa1-c259-49c9-b817-0a2066461c31", + } + ], + "req": { + "topic": "Mi horario diario", + "mode": "Conversation", + "objective": + "Can ask ¿Qué clase tienes? and respond using Tengo clase de… to describe your daily schedule with –ar verbs.", + "media": "nan", + "activity_cefr_level": "A1", + "language_of_instructions": "en", + "target_language": "es", + "number_of_participants": 2, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "QLcvHLeYfheoxeCVJ0K95B8sMCkp0lvtPAur", + }, + { + "title": "Let's Plan Our Weekly Study Timetable!", + "learning_objective": + "Can collaborate to create a balanced weekly timetable, negotiating times and subjects using 'estudiar' and 'trabajar' in the present tense.", + "instructions": + "Work together to create a weekly timetable for your study group. Each person has a role. Use the verbs 'estudiar' (to study) and 'trabajar' (to work) in the present tense to talk about your schedules. Negotiate and decide when you will study and work each day. Use simple phrases like: 'Yo estudio matemáticas el lunes.' or 'Tú trabajas el martes.' Write your final timetable together!", + "vocab": [ + {"lemma": "estudiar", "pos": "VERB"}, + {"lemma": "trabajar", "pos": "VERB"}, + {"lemma": "lunes", "pos": "NOUN"}, + {"lemma": "martes", "pos": "NOUN"}, + {"lemma": "miércoles", "pos": "NOUN"}, + {"lemma": "jueves", "pos": "NOUN"}, + {"lemma": "viernes", "pos": "NOUN"}, + {"lemma": "sábado", "pos": "NOUN"}, + {"lemma": "domingo", "pos": "NOUN"}, + {"lemma": "matemáticas", "pos": "NOUN"}, + {"lemma": "inglés", "pos": "NOUN"}, + {"lemma": "historia", "pos": "NOUN"}, + {"lemma": "ciencias", "pos": "NOUN"}, + {"lemma": "hora", "pos": "NOUN"}, + {"lemma": "mañana", "pos": "NOUN"}, + {"lemma": "tarde", "pos": "NOUN"}, + ], + "roles": [ + { + "name": "Timekeeper", + "id": "92a8805a-2580-4fa3-9863-54113bb3d1cc", + }, + { + "name": "Negotiator", + "id": "9691dc1f-3e0b-43c0-aaa7-e2a99e00f81f", + }, + { + "name": "Note-taker", + "id": "b4644793-0ec5-414f-993f-51d107d795fc", + }, + { + "name": "Presenter", + "id": "a927b3a4-9a11-4b9a-8320-3314646105a5", + } + ], + "req": { + "topic": "Planifica tu semana de clases", + "mode": "Decision Making", + "objective": + "Can collaborate to create a balanced weekly timetable, negotiating times and subjects using estudiar and trabajar in the present tense.", + "media": "nan", + "activity_cefr_level": "A1", + "language_of_instructions": "en", + "target_language": "es", + "number_of_participants": 4, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "Tb2AzaRQOCUvIbwSB6EB9lTlMppkh9xCpr3p", + }, + { + "title": "Parent–Teacher Conference Roleplay", + "learning_objective": + "Can role-play a parent–teacher conference, discussing a student’s clases, materiales del aula, and study habits using present-tense –ar verbs.", + "instructions": + "You will role-play a parent–teacher conference by sending voice messages. One of you is the teacher, and the other is the parent. Use simple Spanish phrases to talk about the student’s classes, classroom materials, and study habits. \n\nTeacher: Start by greeting the parent and talking about the student’s classes. Ask about their study habits at home and mention what materials the student uses in class. Example: \"Hola, señora. Su hijo estudia matemáticas y ciencias. Usa cuadernos y lápices en clase. ¿Cómo estudia en casa?\"\n\nParent: Respond to the teacher’s questions and ask about your child’s progress. Example: \"Hola, maestro. Mi hijo estudia en casa todos los días. Usa libros y una mochila. ¿Trabaja bien en clase?\"\n\nKeep your sentences simple and use present-tense –ar verbs like estudiar, usar, trabajar, hablar.", + "vocab": [ + {"lemma": "estudiar", "pos": "VERB"}, + {"lemma": "usar", "pos": "VERB"}, + {"lemma": "trabajar", "pos": "VERB"}, + {"lemma": "hablar", "pos": "VERB"}, + {"lemma": "clase", "pos": "NOUN"}, + {"lemma": "materiales", "pos": "NOUN"}, + {"lemma": "cuaderno", "pos": "NOUN"}, + {"lemma": "lápiz", "pos": "NOUN"}, + {"lemma": "mochila", "pos": "NOUN"}, + ], + "roles": [ + { + "name": "Teacher", + "id": "32d16a32-6d94-494b-81e6-293f16bd8179", + }, + { + "name": "Parent", + "id": "a0ed388f-4083-400b-9830-a041b1bd04bc", + }, + ], + "req": { + "topic": "Reunión de padres y maestro", + "mode": "Roleplay", + "objective": + "Can role-play a parent–teacher conference, discussing a student’s clases, materiales del aula and study habits with present-tense –ar verbs.", + "media": "voice_messages", + "activity_cefr_level": "A1", + "language_of_instructions": "en", + "target_language": "es", + "number_of_participants": 2, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "NSmlllNXiJFVadF5NKtPjTE3jaWqwFj5HJus", + } + ], + }, + { + "title": "Capítulo 4: Vamos de compras", + "description": + "Learn numbers 0–100, prices, and currency expressions (¿Cuánto cuesta? Cuesta cinco euros). Vocabulary for clothes (la camisa, los pantalones) and common verbs (comprar, pagar). Practice dialogues in a tienda or mercado.", + "uuid": "9023ee26-70ed-433d-a5a5-30c4d9b4a7cd", + "activity_ids": [], + "activities": [ + { + "title": "Store Price Roleplay", + "learning_objective": + "I can ask “¿Cuánto cuesta?” and respond with prices in euros in a tienda.", + "instructions": + "One of you is the customer, and the other is the shopkeeper. The customer asks about the price of different items using the question: “¿Cuánto cuesta...?” The shopkeeper answers with a price in euros, for example: “Cuesta cinco euros.” Use at least three different items. Switch items each time. Example: Customer: “¿Cuánto cuesta la manzana?” Shopkeeper: “Cuesta dos euros.”", + "vocab": [ + {"lemma": "¿Cuánto cuesta...?", "pos": "PHRASE"}, + {"lemma": "Cuesta... euros.", "pos": "PHRASE"}, + {"lemma": "la manzana", "pos": "NOUN"}, + {"lemma": "el pan", "pos": "NOUN"}, + {"lemma": "el libro", "pos": "NOUN"}, + {"lemma": "la camiseta", "pos": "NOUN"}, + {"lemma": "el agua", "pos": "NOUN"}, + ], + "roles": [ + { + "name": "Customer", + "id": "8a606868-2099-454b-ae4e-0d14a0e79bb7", + }, + { + "name": "Shopkeeper", + "id": "46595fd6-fb3b-4de6-ad28-18d8fa0546f7", + } + ], + "req": { + "topic": "Asking and stating prices in a store", + "mode": "Roleplay", + "objective": + "I can ask “¿Cuánto cuesta?” and respond with prices in euros in a tienda.", + "media": "nan", + "activity_cefr_level": "A1", + "language_of_instructions": "en", + "target_language": "es", + "number_of_participants": 2, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "lQHfICh0zlE9gLzFDRV1e8gv5d1VIkQ4n1Hu", + }, + { + "title": + "Clothing Scavenger Hunt: Find the Right Items and Prices!", + "learning_objective": + "I can recognize clothing vocabulary and read price tags to collect specific items.", + "instructions": + "Each of you will receive a shopping list with clothing items and prices in Spanish. Your task is to find and send an image (from the internet or your camera roll) that matches each item and its price tag. Use Spanish to confirm your choices. For example: \"Aquí está la camisa. Cuesta 10 euros.\" When you finish your list, help your teammates if they need it!", + "vocab": [ + {"lemma": "camisa", "pos": "NOUN"}, + {"lemma": "pantalones", "pos": "NOUN"}, + {"lemma": "falda", "pos": "NOUN"}, + {"lemma": "zapatos", "pos": "NOUN"}, + {"lemma": "abrigo", "pos": "NOUN"}, + {"lemma": "precio", "pos": "NOUN"}, + {"lemma": "cuesta", "pos": "VERB"}, + {"lemma": "euro", "pos": "NOUN"}, + ], + "roles": [ + { + "name": "Shopper 1", + "id": "82f8982d-1be1-4e39-b428-c2b0b1cc568e", + }, + { + "name": "Shopper 2", + "id": "476e4b1d-e3f2-49c5-ad91-0d52d4f18897", + }, + { + "name": "Shopper 3", + "id": "84902d3f-f55b-461e-aa89-f654157d291f", + } + ], + "req": { + "topic": "Identifying clothing items and prices", + "mode": "Scavenger Hunt", + "objective": + "I can recognize clothing vocabulary and read price tags to collect specific items.", + "media": "images", + "activity_cefr_level": "A1", + "language_of_instructions": "en", + "target_language": "es", + "number_of_participants": 3, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "dlvtTPlP9xHc4QHGgtEXOWLhoCqNpnxijh3x", + }, + { + "title": "Where Should We Shop? Comparing Prices", + "learning_objective": + "I can compare prices using expressions like más barato/más caro and decide where to shop.", + "instructions": + "You are three friends deciding where to buy items for a party. Each of you has information about prices from different stores. Take turns sharing the prices you know. Use phrases like 'En la tienda A, el pan es más barato' or 'En la tienda B, la leche es más cara.' Discuss and decide together where you should buy each item to save money. Use the target phrases as much as possible!", + "vocab": [ + {"lemma": "más barato", "pos": "ADJ"}, + {"lemma": "más caro", "pos": "ADJ"}, + {"lemma": "precio", "pos": "NOUN"}, + {"lemma": "tienda", "pos": "NOUN"}, + {"lemma": "comprar", "pos": "VERB"}, + {"lemma": "pan", "pos": "NOUN"}, + {"lemma": "leche", "pos": "NOUN"}, + {"lemma": "ahorrar", "pos": "VERB"}, + ], + "roles": [ + { + "name": "Friend 1 (has prices from Store A)", + "id": "7827f151-019f-4494-bafe-0c24ac424a3d", + }, + { + "name": "Friend 2 (has prices from Store B)", + "id": "2f653777-9071-4111-a33a-3ed3c161d96d", + }, + { + "name": "Friend 3 (has prices from Store C)", + "id": "fac0130b-966b-4d4e-83d5-5920f5ff7a6b", + } + ], + "req": { + "topic": "Comparing prices across stores", + "mode": "Conversation", + "objective": + "I can compare prices using expresiones como más barato/más caro and decide where to shop.", + "media": "nan", + "activity_cefr_level": "A1", + "language_of_instructions": "en", + "target_language": "es", + "number_of_participants": 3, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "Qh4AajZFJUs1cUKrSD3qtvgVRdzYE7C3mhxd", + }, + { + "title": "Guess the Price! (20-Question Clothing Game)", + "learning_objective": + "I can use yes/no questions to guess the price of a clothing item in euros.", + "instructions": + "Role 1: You are the Questioner. Think of yes/no questions in Spanish to guess the price of a clothing item (between 1 and 50 euros). Send your questions as voice messages. Example: ¿Cuesta más de 10 euros? Role 2: You are the Responder. Choose a clothing item and its price (between 1 and 50 euros). Answer only with 'sí' or 'no' in voice messages. After 20 questions or when the Questioner is ready, they can guess the price in Spanish. Example: ¿Cuesta 15 euros?", + "vocab": [ + {"lemma": "¿Cuesta...?", "pos": "VERB"}, + {"lemma": "más", "pos": "ADV"}, + {"lemma": "menos", "pos": "ADV"}, + {"lemma": "euros", "pos": "NOUN"}, + {"lemma": "sí", "pos": "ADV"}, + {"lemma": "no", "pos": "ADV"}, + {"lemma": "precio", "pos": "NOUN"}, + {"lemma": "camisa", "pos": "NOUN"}, + {"lemma": "pantalón", "pos": "NOUN"}, + {"lemma": "falda", "pos": "NOUN"}, + {"lemma": "abrigo", "pos": "NOUN"}, + ], + "roles": [ + { + "name": "Questioner", + "id": "13bd6a74-283e-4225-b2bc-73a856783d2e", + }, + { + "name": "Responder", + "id": "3a2ff818-853a-436a-8ee0-20eb64cf019e", + } + ], + "req": { + "topic": "Price guessing game", + "mode": "20-Question Game", + "objective": + "I can use yes/no questions to guess the price of a clothing item in euros.", + "media": "voice_messages", + "activity_cefr_level": "A1", + "language_of_instructions": "en", + "target_language": "es", + "number_of_participants": 2, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "22fe97blD5YS8wYy05rPEXYFT9N1JmqwrCzX", + }, + { + "title": "Build Your Outfit: Shopping List and Budget", + "learning_objective": + "I can plan a shopping list and allocate a budget to buy multiple pieces of clothing.", + "instructions": + "Work together to create a shopping list for an outfit. You have a budget of 50 euros. Each of you chooses one clothing item to add to the list, says its price (in euros), and says why you chose it. Use simple Spanish phrases, for example: \"Quiero comprar una camisa. Cuesta 15 euros. Es bonita.\" Make sure your total is 50 euros or less! At the end, check your list and total together.", + "vocab": [ + {"lemma": "camisa", "pos": "NOUN"}, + {"lemma": "pantalón", "pos": "NOUN"}, + {"lemma": "falda", "pos": "NOUN"}, + {"lemma": "zapatos", "pos": "NOUN"}, + {"lemma": "abrigo", "pos": "NOUN"}, + {"lemma": "precio", "pos": "NOUN"}, + {"lemma": "comprar", "pos": "VERB"}, + {"lemma": "elegir", "pos": "VERB"}, + {"lemma": "euros", "pos": "NOUN"}, + {"lemma": "lista", "pos": "NOUN"}, + ], + "roles": [ + { + "name": "Buyer 1", + "id": "179ae7a5-36dd-4649-b97d-90d7fe7b45ea", + }, + { + "name": "Buyer 2", + "id": "00979674-90fe-48bc-aba1-8edd2753134b", + }, + { + "name": "Buyer 3", + "id": "51b3ef7e-a39d-48cf-b629-5ade56aa75eb", + }, + { + "name": "Buyer 4", + "id": "cac0e2e6-a5a6-4494-aafc-2119bc072d29", + } + ], + "req": { + "topic": "Budget planning for an outfit", + "mode": "Decision Making", + "objective": + "I can plan a shopping list and allocate a budget to buy multiple pieces of clothing.", + "media": "nan", + "activity_cefr_level": "A1", + "language_of_instructions": "en", + "target_language": "es", + "number_of_participants": 4, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "8VrlspY2Fuz4hql1woVlFZdb01uRmZwdbJjD", + } + ], + }, + { + "title": "Capítulo 5: La comida y las comidas", + "description": + "Explore vocabulary for food groups (frutas, verduras, carne), meals of the day (desayuno, almuerzo, cena), learn gustar and similar verbs, and practice ordering in a café o restaurante.", + "uuid": "a4ab2c2f-e034-482a-9683-89b8b9345350", + "activity_ids": [], + "activities": [ + { + "title": "Food Scavenger Hunt: Classifying Foods", + "learning_objective": + "I can identify and categorize foods into fruits, vegetables, and meats.", + "instructions": + "Each of you will play a different role. The Questioner will ask for a type of food (fruit, vegetable, or meat) in Spanish. The Finders will look for an image (from the internet or your camera roll) that matches the category and send it to the group. Then, everyone says the name of the food in Spanish and which category it belongs to. Example: \"¿Puedes encontrar una fruta?\" Finder: [sends image of an apple] \"Es una manzana. Es una fruta.\" Repeat with different categories. Take turns asking for different categories.", + "vocab": [ + {"lemma": "fruta", "pos": "NOUN"}, + {"lemma": "verdura", "pos": "NOUN"}, + {"lemma": "carne", "pos": "NOUN"}, + {"lemma": "manzana", "pos": "NOUN"}, + {"lemma": "plátano", "pos": "NOUN"}, + {"lemma": "zanahoria", "pos": "NOUN"}, + {"lemma": "lechuga", "pos": "NOUN"}, + {"lemma": "pollo", "pos": "NOUN"}, + {"lemma": "pescado", "pos": "NOUN"}, + {"lemma": "tomate", "pos": "NOUN"}, + ], + "roles": [ + { + "name": "Questioner", + "id": "f9fb6bc6-ee93-482d-84f6-349640f897d6", + }, + { + "name": "Finder 1", + "id": "5681b90e-0f89-4c9b-a5c3-7fc9fa735c07", + }, + { + "name": "Finder 2", + "id": "f2b8d944-0643-4ede-bbaa-1d4378955e7c", + } + ], + "req": { + "topic": "Clasificación de alimentos", + "mode": "Scavenger Hunt", + "objective": + "I can identify and categorize foods into fruits, vegetables, and meats.", + "media": "images", + "activity_cefr_level": "A1", + "language_of_instructions": "en", + "target_language": "es", + "number_of_participants": 3, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "C3pbT9kz44QwOTPPpGLlxsBOHHDpL3SDcXI4", + }, + { + "title": "Food Preferences Interview", + "learning_objective": + "I can ask and answer questions about food preferences using me gusta, me encanta, and no me gusta.", + "instructions": + "You will each take a role. The Questioner asks the Responder about their food preferences using questions like: ¿Te gusta el pan? ¿Te encanta la pizza? ¿No te gusta el pescado? The Responder answers using me gusta, me encanta, or no me gusta, for example: Me gusta el pan, me encanta la pizza, no me gusta el pescado. Record and send your questions and answers as voice messages. Try at least 5 different foods from the vocab list. Then, switch roles and repeat if you wish.", + "vocab": [ + {"lemma": "me gusta", "pos": "EXP"}, + {"lemma": "me encanta", "pos": "EXP"}, + {"lemma": "no me gusta", "pos": "EXP"}, + {"lemma": "el pan", "pos": "NOUN"}, + {"lemma": "la pizza", "pos": "NOUN"}, + {"lemma": "el pescado", "pos": "NOUN"}, + {"lemma": "la fruta", "pos": "NOUN"}, + {"lemma": "el arroz", "pos": "NOUN"}, + {"lemma": "el queso", "pos": "NOUN"}, + {"lemma": "la sopa", "pos": "NOUN"}, + ], + "roles": [ + { + "name": "Questioner", + "id": "b839373c-4edd-46d2-a550-57534e2323e4", + }, + { + "name": "Responder", + "id": "05c81eac-4a08-48fc-9e2e-71972543676c", + } + ], + "req": { + "topic": "Expresar gustos y disgustos", + "mode": "Conversation", + "objective": + "I can ask and answer questions about food preferences using me gusta, me encanta, and no me gusta.", + "media": "voice_messages", + "activity_cefr_level": "A1", + "language_of_instructions": "en", + "target_language": "es", + "number_of_participants": 2, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "60mhCRHIK203HWyKnldDKt0zhsDq5T8ckBw7", + }, + { + "title": "Guess the Secret Food! (20-Question Game)", + "learning_objective": + "I can ask and respond to yes/no questions to guess a secret food item.", + "instructions": + "One of you will think of a secret food (comida secreta). The other will ask yes/no questions in Spanish to guess what it is. You can ask about color, taste, size, or type. Use simple Spanish questions. Example questions: ¿Es dulce? (Is it sweet?) ¿Es una fruta? (Is it a fruit?) ¿Es grande? (Is it big?) The Responder can only answer 'sí' (yes) or 'no'. You have up to 20 questions to guess the food!", + "vocab": [ + {"lemma": "comida", "pos": "NOUN"}, + {"lemma": "fruta", "pos": "NOUN"}, + {"lemma": "verdura", "pos": "NOUN"}, + {"lemma": "carne", "pos": "NOUN"}, + {"lemma": "dulce", "pos": "ADJ"}, + {"lemma": "salado", "pos": "ADJ"}, + {"lemma": "grande", "pos": "ADJ"}, + {"lemma": "pequeño", "pos": "ADJ"}, + {"lemma": "¿Es...?", "pos": "PHRASE"}, + {"lemma": "sí", "pos": "ADV"}, + {"lemma": "no", "pos": "ADV"}, + ], + "roles": [ + { + "name": "Questioner", + "id": "866bcd4e-38f4-47d0-9037-43d3b893f4a2", + }, + { + "name": "Responder", + "id": "161415a3-8822-43b0-969e-47a79092684f", + } + ], + "req": { + "topic": "Juego de adivinar comidas", + "mode": "20-Question Game", + "objective": + "I can ask and respond to yes/no questions to guess a secret food item.", + "media": "nan", + "activity_cefr_level": "A1", + "language_of_instructions": "en", + "target_language": "es", + "number_of_participants": 2, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "4w6fdeKCYC0a1ioR1ofNn23PCYpJ9ODg4QeH", + }, + { + "title": "Roleplay: Ordering at a Restaurant", + "learning_objective": + "I can role-play ordering a meal, ask for the bill, and respond to waiter questions in a restaurant setting.", + "instructions": + "You will role-play a conversation in a Spanish restaurant. One of you is the waiter, and the other is the customer. Use simple Spanish phrases to order food, ask for the bill, and answer questions. Example phrases:\n- Para mí, una ensalada, por favor.\n- ¿Algo para beber?\n- La cuenta, por favor.\n- ¿Desea postre?\nTry to use the target vocabulary in your conversation.", + "vocab": [ + {"lemma": "la cuenta", "pos": "NOUN"}, + {"lemma": "el menú", "pos": "NOUN"}, + {"lemma": "el camarero / la camarera", "pos": "NOUN"}, + {"lemma": "el cliente / la clienta", "pos": "NOUN"}, + {"lemma": "pedir", "pos": "VERB"}, + {"lemma": "quiero", "pos": "VERB"}, + {"lemma": "para mí", "pos": "PHRASE"}, + {"lemma": "por favor", "pos": "PHRASE"}, + {"lemma": "gracias", "pos": "PHRASE"}, + {"lemma": "¿Algo para beber?", "pos": "PHRASE"}, + {"lemma": "¿Desea postre?", "pos": "PHRASE"}, + ], + "roles": [ + { + "name": "Waiter", + "id": "1b3960bf-8f3e-4706-87f8-ed12955f634c", + }, + { + "name": "Customer", + "id": "22744efd-8d7f-4db4-988c-1589eef4e9f7", + } + ], + "req": { + "topic": "Pedir en un restaurante", + "mode": "Roleplay", + "objective": + "I can role-play ordering a meal, pedir la cuenta, and responding to waiter questions.", + "media": "nan", + "activity_cefr_level": "A1", + "language_of_instructions": "en", + "target_language": "es", + "number_of_participants": 2, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "uNzzS2o86zXZXf3tt4Sdi34WlQz3K33eJobH", + }, + { + "title": "Let's Plan Our Daily Meals!", + "learning_objective": + "I can plan and discuss my meals of the day (desayuno, almuerzo, cena) and justify my choices.", + "instructions": + "Work together to create a meal plan for the day (desayuno, almuerzo, cena). Each of you will have a different role. Use simple Spanish to suggest meals and explain your choices. Example phrases: \"Para el desayuno, yo quiero pan porque es delicioso.\" \"¿Por qué eliges sopa para el almuerzo?\" \"Prefiero arroz para la cena porque es fácil.\" Discuss and decide together on the final menu for each meal.", + "vocab": [ + {"lemma": "desayuno", "pos": "NOUN"}, + {"lemma": "almuerzo", "pos": "NOUN"}, + {"lemma": "cena", "pos": "NOUN"}, + {"lemma": "pan", "pos": "NOUN"}, + {"lemma": "arroz", "pos": "NOUN"}, + {"lemma": "sopa", "pos": "NOUN"}, + {"lemma": "pollo", "pos": "NOUN"}, + {"lemma": "fruta", "pos": "NOUN"}, + {"lemma": "agua", "pos": "NOUN"}, + {"lemma": "porque", "pos": "CONJ"}, + {"lemma": "quiero", "pos": "VERB"}, + {"lemma": "prefiero", "pos": "VERB"}, + {"lemma": "delicioso", "pos": "ADJ"}, + {"lemma": "fácil", "pos": "ADJ"}, + ], + "roles": [ + { + "name": "Meal Suggester", + "id": "4c2e375f-ce23-49e0-9e5e-853b6eb34c9f", + }, + { + "name": "Meal Questioner", + "id": "4349adcc-49ea-46ea-8448-3daf74a5e954", + }, + { + "name": "Menu Writer", + "id": "fd2f6e0c-c0c7-401a-838c-756a3eccfa90", + } + ], + "req": { + "topic": "Plan de comidas del día", + "mode": "Decision Making", + "objective": + "I can plan and discuss my meals of the day (desayuno, almuerzo, cena) and justify my choices.", + "media": "nan", + "activity_cefr_level": "A1", + "language_of_instructions": "en", + "target_language": "es", + "number_of_participants": 3, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "NoJQToZIPNBZGSPmxAhA08vvRDPTxAKtibwL", + } + ], + }, + { + "title": "Capítulo 6: Mi casa y mi vecindario", + "description": + "Name rooms (la cocina, el baño, el dormitorio), furniture (la mesa, la silla) and describe your home’s location using estar + prepositions. Use the verb haber to talk about what there is in your neighborhood (En mi barrio hay….)", + "uuid": "5017b2f4-bd67-4d2a-8a8f-359bbcce6c90", + "activity_ids": [], + "activities": [ + { + "title": "Show & Describe: Rooms and Furniture", + "learning_objective": + "I can name and describe rooms (la cocina, el baño, el dormitorio) and furniture (la mesa, la silla) in my house.", + "instructions": + "1. Each of you will choose or find an image of a room in a house (kitchen, bathroom, or bedroom) and share it in the chat.\n2. Questioner: Ask your partner questions in Spanish about their image. Example: \"¿Qué hay en la cocina? ¿Dónde está la mesa?\"\n3. Responder: Describe the room and the furniture in Spanish using simple sentences. Example: \"En la cocina hay una mesa y dos sillas. La mesa es grande.\"\n4. Use the target vocabulary as much as possible.", + "vocab": [ + {"lemma": "la cocina", "pos": "NOUN"}, + {"lemma": "el baño", "pos": "NOUN"}, + {"lemma": "el dormitorio", "pos": "NOUN"}, + {"lemma": "la mesa", "pos": "NOUN"}, + {"lemma": "la silla", "pos": "NOUN"}, + ], + "roles": [ + { + "name": "Questioner", + "id": "75844e3e-1257-492b-bd43-0fc448354232", + }, + { + "name": "Responder", + "id": "bc400b39-63c6-4887-b07d-144606108fe0", + } + ], + "req": { + "topic": "Rooms and furniture vocabulary", + "mode": "Conversation", + "objective": + "I can name and describe rooms (la cocina, el baño, el dormitorio) and furniture (la mesa, la silla) in my house.", + "media": "images", + "activity_cefr_level": "A1", + "language_of_instructions": "en", + "target_language": "es", + "number_of_participants": 2, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "VldXc1engUYbXp4Q52zV9vXcHwQCGgka7Xwe", + }, + { + "title": "Scavenger Hunt: Where Is It?", + "learning_objective": + "I can describe the location of objects and furniture in different rooms using estar + prepositions (al lado de, cerca de, enfrente de).", + "instructions": + "Each of you will play a different role. The \"Clue Giver\" describes where an object or piece of furniture is located in a room using estar + prepositions (for example: \"La lámpara está al lado del sofá\"). The \"Finder\" listens and guesses which object or furniture is being described. The \"Recorder\" writes down the sentences and keeps track of correct guesses. Use the example phrases: \"¿Dónde está la mesa?\", \"La mesa está enfrente de la ventana.\" Take turns in your roles for each round.", + "vocab": [ + {"lemma": "estar", "pos": "VERB"}, + {"lemma": "al lado de", "pos": "PREP"}, + {"lemma": "cerca de", "pos": "PREP"}, + {"lemma": "enfrente de", "pos": "PREP"}, + {"lemma": "mesa", "pos": "NOUN"}, + {"lemma": "silla", "pos": "NOUN"}, + {"lemma": "sofá", "pos": "NOUN"}, + {"lemma": "ventana", "pos": "NOUN"}, + {"lemma": "puerta", "pos": "NOUN"}, + {"lemma": "lámpara", "pos": "NOUN"}, + ], + "roles": [ + { + "name": "Clue Giver", + "id": "2e8d5533-1a19-476f-8c55-55e9894ff50d", + }, + { + "name": "Finder", + "id": "7120797d-6950-48fc-af72-8f954001f26a", + }, + { + "name": "Recorder", + "id": "25a845bc-87e0-4494-91d8-808cdb1ea8c9", + } + ], + "req": { + "topic": "Describing locations with estar + prepositions", + "mode": "Scavenger Hunt", + "objective": + "I can describe the location of objects and furniture in different rooms using estar + prepositions (al lado de, cerca de, enfrente de).", + "media": "nan", + "activity_cefr_level": "A1", + "language_of_instructions": "en", + "target_language": "es", + "number_of_participants": 3, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "nd8t5su7XuO5EmucCzka5agngYqd9Saozm2z", + }, + { + "title": "Neighborhood Detective Roleplay", + "learning_objective": + "I can ask and answer questions about what there is in my neighborhood using 'haber' (En mi barrio hay…).", + "instructions": + "You will each send voice messages in Spanish. One of you is the Detective and the other is the Neighbor. The Detective asks questions about what is in the neighborhood using '¿Hay… en tu barrio?' (Is there... in your neighborhood?). The Neighbor answers using 'En mi barrio hay/no hay...' (In my neighborhood there is/there isn’t...). \n\nExample:\nDetective: ¿Hay un parque en tu barrio?\nNeighbor: Sí, en mi barrio hay un parque.\nDetective: ¿Hay una farmacia en tu barrio?\nNeighbor: No, en mi barrio no hay una farmacia.\n\nTake turns asking and answering at least 4 different questions. Use the vocabulary list to help you.", + "vocab": [ + {"lemma": "parque", "pos": "NOUN"}, + {"lemma": "supermercado", "pos": "NOUN"}, + {"lemma": "escuela", "pos": "NOUN"}, + {"lemma": "farmacia", "pos": "NOUN"}, + {"lemma": "restaurante", "pos": "NOUN"}, + {"lemma": "panadería", "pos": "NOUN"}, + {"lemma": "cine", "pos": "NOUN"}, + {"lemma": "biblioteca", "pos": "NOUN"}, + {"lemma": "hay", "pos": "VERB"}, + ], + "roles": [ + { + "name": "Detective", + "id": "cb9e48a3-ebc5-4cf2-8053-a7be6c78c142", + }, + { + "name": "Neighbor", + "id": "6c14feb3-3153-4763-a73f-e23f801039b8", + } + ], + "req": { + "topic": "Talking about neighborhood features with haber", + "mode": "Roleplay", + "objective": + "I can ask and answer questions about what there is in my neighborhood using haber (En mi barrio hay…).", + "media": "voice_messages", + "activity_cefr_level": "A1", + "language_of_instructions": "en", + "target_language": "es", + "number_of_participants": 2, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "I3OUcXatRfRX0VAWK4nkbKgsAFBKtyo59Z4G", + }, + { + "title": "Design Your Dream House", + "learning_objective": + "I can decide where to place rooms and furniture in a house layout and explain my choices in Spanish.", + "instructions": + "Work together to design a house! Each of you has a role. Use the Spanish vocabulary provided to decide where to place rooms and furniture. Explain your choices using simple Spanish sentences. Example phrases: \"El sofá está en el salón porque es cómodo\" or \"La cama está en el dormitorio.\" Ask and answer questions about the house layout. Use as much Spanish as possible!", + "vocab": [ + {"lemma": "cocina", "pos": "NOUN"}, + {"lemma": "salón", "pos": "NOUN"}, + {"lemma": "dormitorio", "pos": "NOUN"}, + {"lemma": "baño", "pos": "NOUN"}, + {"lemma": "sofá", "pos": "NOUN"}, + {"lemma": "cama", "pos": "NOUN"}, + {"lemma": "mesa", "pos": "NOUN"}, + {"lemma": "silla", "pos": "NOUN"}, + {"lemma": "poner", "pos": "VERB"}, + {"lemma": "porque", "pos": "CONJ"}, + ], + "roles": [ + { + "name": "Planner", + "id": "be0db63c-2220-445a-bba5-22874b9682a0", + }, + { + "name": "Designer", + "id": "3e825349-dffc-402c-a675-37a1f40ee81c", + }, + { + "name": "Questioner", + "id": "c616aebe-733b-4744-9ad0-6a5439379c1e", + } + ], + "req": { + "topic": "Planning a house layout", + "mode": "Decision Making", + "objective": + "I can decide where to place rooms and furniture in a house layout and explain my choices in Spanish.", + "media": "nan", + "activity_cefr_level": "A1", + "language_of_instructions": "en", + "target_language": "es", + "number_of_participants": 3, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "9BQqOjK29pLZljbVLqRxoA6403G5Cd1OLZqX", + }, + { + "title": "20-Question Game: Guess the Room or Furniture!", + "learning_objective": + "I can use yes/no questions to identify a room or piece of furniture using target vocabulary.", + "instructions": + "One of you is the Responder and secretly chooses a room or piece of furniture from the list. The other is the Questioner and asks yes/no questions in Spanish to guess what it is. You have up to 20 questions! Use questions like: ¿Es grande? ¿Está en la cocina? ¿Se puede sentar en esto? The Responder only answers sí or no. The Questioner tries to guess before reaching 20 questions.", + "vocab": [ + {"lemma": "cocina", "pos": "NOUN"}, + {"lemma": "baño", "pos": "NOUN"}, + {"lemma": "salón", "pos": "NOUN"}, + {"lemma": "dormitorio", "pos": "NOUN"}, + {"lemma": "silla", "pos": "NOUN"}, + {"lemma": "mesa", "pos": "NOUN"}, + {"lemma": "sofá", "pos": "NOUN"}, + {"lemma": "cama", "pos": "NOUN"}, + ], + "roles": [ + { + "name": "Questioner", + "id": "0d89d8c1-d2e6-4ca7-aeec-35de35fdccf3", + }, + { + "name": "Responder", + "id": "f97f7b8a-d55b-4741-9b93-7916799f30cd", + } + ], + "req": { + "topic": "Guessing rooms or furniture", + "mode": "20-Question Game", + "objective": + "I can use yes/no questions to identify a room or piece of furniture using target vocabulary.", + "media": "nan", + "activity_cefr_level": "A1", + "language_of_instructions": "en", + "target_language": "es", + "number_of_participants": 2, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "49z9fOL9xUK4Sl6Ku2Fk4a1gsCWYMcqPy9tX", + } + ], + }, + { + "title": "Capítulo 7: El clima y las estaciones", + "description": + "Talk about weather (hace sol, llueve, nieva) and seasons (la primavera, el verano). Use estar + adjectives (está nublado, está despejado). Ask and answer: ¿Qué tiempo hace hoy?", + "uuid": "d0610873-37fb-4cd2-91cc-28a76b17cf75", + "activity_ids": [], + "activities": [ + { + "title": "Guess the Weather: 20-Question Game", + "learning_objective": + "I can ask yes/no questions to guess a weather condition using vocabulary like hace sol, está nublado, and llueve.", + "instructions": + "One of you is the Responder and secretly chooses a weather condition (for example: hace sol, está nublado, or llueve). The other is the Questioner and asks yes/no questions in Spanish to guess it. Use simple questions like: ¿Hace sol? ¿Está nublado? ¿Llueve? The Responder answers with 'sí' or 'no'. The Questioner has up to 20 questions to guess the weather condition. Switch roles after finishing if you like.", + "vocab": [ + {"lemma": "hace sol", "pos": "expression"}, + {"lemma": "está nublado", "pos": "expression"}, + {"lemma": "llueve", "pos": "verb"}, + {"lemma": "sí", "pos": "adverb"}, + {"lemma": "no", "pos": "adverb"}, + {"lemma": "¿Hace sol?", "pos": "question"}, + {"lemma": "¿Está nublado?", "pos": "question"}, + {"lemma": "¿Llueve?", "pos": "question"}, + ], + "roles": [ + { + "name": "Questioner", + "id": "ae726eb7-156e-4ac3-9972-64018b2e753c", + }, + { + "name": "Responder", + "id": "437280f3-1997-404e-9799-71041b5d06cd", + } + ], + "req": { + "topic": "Guess the Weather Condition", + "mode": "20-Question Game", + "objective": + "I can ask yes/no questions to guess a weather condition using vocabulary like hace sol, está nublado, and llueve.", + "media": "nan", + "activity_cefr_level": "A1", + "language_of_instructions": "en", + "target_language": "es", + "number_of_participants": 2, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "Vtj9Dw9n1mAaFKkgxkrocSDDhefScquU45oc", + }, + { + "title": "Weather Voice Chat", + "learning_objective": + "I can ask and answer ¿Qué tiempo hace hoy? and respond with Está… or Hace… statements.", + "instructions": + "Learner 1: Record a voice message asking your partner about the weather today using the question: ¿Qué tiempo hace hoy? \nLearner 2: Listen to the message and reply with a voice message describing the weather using \"Está...\" or \"Hace...\" For example: \"Está soleado\" or \"Hace frío\". \nUse the vocabulary list below for ideas. Try to make your answer complete!", + "vocab": [ + {"lemma": "¿Qué tiempo hace hoy?", "pos": "PHRASE"}, + {"lemma": "Está soleado", "pos": "PHRASE"}, + {"lemma": "Está nublado", "pos": "PHRASE"}, + {"lemma": "Hace calor", "pos": "PHRASE"}, + {"lemma": "Hace frío", "pos": "PHRASE"}, + {"lemma": "Hace viento", "pos": "PHRASE"}, + {"lemma": "Hace buen/mal tiempo", "pos": "PHRASE"}, + {"lemma": "Está lloviendo", "pos": "PHRASE"}, + ], + "roles": [ + { + "name": "Weather Asker", + "id": "fbcb6310-825d-472c-b156-c8b28fe1ceed", + }, + { + "name": "Weather Responder", + "id": "16362704-d935-4f19-8d31-360dc67af89e", + } + ], + "req": { + "topic": "Daily Weather Chat", + "mode": "Conversation", + "objective": + "I can ask and answer ¿Qué tiempo hace hoy? and respond with Está… or Hace… statements.", + "media": "voice_messages", + "activity_cefr_level": "A1", + "language_of_instructions": "en", + "target_language": "es", + "number_of_participants": 2, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "htTDcmJAqYcKrjk8W8OG8wn56Roovu8n3WkZ", + }, + { + "title": "Weather Photo Scavenger Hunt", + "learning_objective": + "I can match images to weather expressions like llueve, nieva, hace viento, and está despejado.", + "instructions": + "Each of you will look for a photo (online or from your own collection) that matches one of these weather phrases: 'llueve' (it's raining), 'nieva' (it's snowing), 'hace viento' (it's windy), or 'está despejado' (it's clear). When you find a photo, send it to the group chat. Then, take turns guessing which Spanish weather phrase matches each photo. Use phrases like: 'Creo que es... porque...' (I think it is... because...). For example: 'Creo que es hace viento porque veo árboles moviéndose.'", + "vocab": [ + {"lemma": "llueve", "pos": "VERB"}, + {"lemma": "nieva", "pos": "VERB"}, + {"lemma": "hace viento", "pos": "VERB + NOUN"}, + {"lemma": "está despejado", "pos": "VERB + ADJ"}, + ], + "roles": [ + { + "name": "Photo Finder 1", + "id": "1ddfc049-f8b9-4466-a211-6ce29ebe152c", + }, + { + "name": "Photo Finder 2", + "id": "bedf7c4b-c598-43e9-bb62-fbed1430813a", + }, + { + "name": "Photo Finder 3", + "id": "01ec9c10-10c0-4838-be5d-c20dba183df3", + } + ], + "req": { + "topic": "Weather Photo Scavenger Hunt", + "mode": "Scavenger Hunt", + "objective": + "I can match images to weather expressions like llueve, nieva, hace viento, and está despejado.", + "media": "images", + "activity_cefr_level": "A1", + "language_of_instructions": "en", + "target_language": "es", + "number_of_participants": 3, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "lEC9MFZxDYMN9XlDHNebOJuOLaFqlDMwm0Pt", + }, + { + "title": "Which Season Do You Prefer? Mini-Debate", + "learning_objective": + "I can express my preference for a season and give simple reasons using phrases like 'Prefiero el verano porque…' or 'Me gusta la primavera porque…'.", + "instructions": + "Each of you will choose your favorite season (primavera, verano, otoño, invierno). Take turns saying which season you prefer and give a simple reason using a Spanish phrase. Example: 'Prefiero el verano porque hace calor.' The Questioner will ask, '¿Por qué te gusta esa estación?' The Responder answers. The Supporter says something positive about the answer. The Timekeeper makes sure everyone gets a turn. Use the example phrases to help you.", + "vocab": [ + {"lemma": "prefiero", "pos": "VERB"}, + {"lemma": "me gusta", "pos": "VERB"}, + {"lemma": "porque", "pos": "CONJ"}, + {"lemma": "primavera", "pos": "NOUN"}, + {"lemma": "verano", "pos": "NOUN"}, + {"lemma": "otoño", "pos": "NOUN"}, + {"lemma": "invierno", "pos": "NOUN"}, + {"lemma": "hace calor", "pos": "PHRASE"}, + {"lemma": "hace frío", "pos": "PHRASE"}, + {"lemma": "hay flores", "pos": "PHRASE"}, + {"lemma": "nieve", "pos": "NOUN"}, + ], + "roles": [ + { + "name": "Questioner", + "id": "a01b7902-2c63-4a31-8e4f-12bfd35ef5f1", + }, + { + "name": "Responder", + "id": "c573f1c8-7132-4524-bb6a-e8ddc5e9d54e", + }, + { + "name": "Supporter", + "id": "02bf1b6e-4b4c-4e22-af29-234261d01bdb", + }, + { + "name": "Timekeeper", + "id": "b4eb3a23-1d84-431d-9642-8da1465671cb", + } + ], + "req": { + "topic": "Preferable Season Debate", + "mode": "Debate", + "objective": + "I can express my preference for a season and give simple reasons using phrases like Prefiero el verano porque… or Me gusta la primavera porque….", + "media": "nan", + "activity_cefr_level": "A1", + "language_of_instructions": "en", + "target_language": "es", + "number_of_participants": 4, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "q9vSwQE7Q8SMcJ3IuZ1aOn65AQk4oLCo4xTd", + }, + { + "title": "Weather Showdown: Choose Your Vacation!", + "learning_objective": + "I can compare seasonal weather patterns and decide on a vacation destination using simple sentences.", + "instructions": + "Each of you will represent a different city (choose from: Madrid, Buenos Aires, or París). Research or imagine the weather in your city during summer and winter. Take turns describing the weather in your city using phrases like 'En verano hace calor y está soleado' or 'En invierno está nublado y hace frío.' Then, discuss together which city is better for a vacation in summer or winter, and why. Use simple Spanish sentences to compare. Example phrases: 'En verano, Madrid hace calor. En invierno, París está nublado.' Decide together which city you would visit and explain your choice in Spanish.", + "vocab": [ + {"lemma": "hace calor", "pos": "PHRASE"}, + {"lemma": "hace frío", "pos": "PHRASE"}, + {"lemma": "está nublado", "pos": "PHRASE"}, + {"lemma": "está soleado", "pos": "PHRASE"}, + {"lemma": "en verano", "pos": "PHRASE"}, + {"lemma": "en invierno", "pos": "PHRASE"}, + {"lemma": "ciudad", "pos": "NOUN"}, + {"lemma": "vacaciones", "pos": "NOUN"}, + ], + "roles": [ + { + "name": "City Representative: Madrid", + "id": "1dd72dd5-dbff-40de-952b-7520a3347165", + }, + { + "name": "City Representative: Buenos Aires", + "id": "82e0a6d7-285e-4d52-a13c-b346815bc460", + } + ], + "req": { + "topic": "Vacation Destination Decision", + "mode": "Decision Making", + "objective": + "I can compare seasonal weather patterns and decide on a vacation destination using sentences like En invierno está nublado y hace frío.", + "media": "nan", + "activity_cefr_level": "A1", + "language_of_instructions": "en", + "target_language": "es", + "number_of_participants": 2, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "OkJvwdaPEM9dxuThkUKLLVCLZrDD2swTzvoJ", + } + ], + }, + { + "title": "Capítulo 8: Mi rutina diaria", + "description": + "Learn reflexive verbs (levantarse, ducharse, vestirse) to describe your daily routine. Practice the present tense and time expressions (a las siete, por la mañana). Example: Me levanto a las seis.", + "uuid": "dd9b22c7-441f-4c49-90d2-74a0f4a65c47", + "activity_ids": [], + "activities": [ + { + "title": "Morning Routine Interview", + "learning_objective": + "I can ask and answer questions using reflexive verbs and time expressions to describe my partner’s morning routine.", + "instructions": + "Work in pairs. One of you is the Interviewer and the other is the Interviewee. The Interviewer will ask questions in Spanish about the Interviewee’s morning routine using reflexive verbs and time expressions. The Interviewee will answer in Spanish. Use the example phrases below to help you. \n\nExample questions:\n- ¿A qué hora te despiertas?\n- ¿Te duchas por la mañana?\n- ¿Cuándo te cepillas los dientes?\n- ¿Te vistes antes o después de desayunar?\n\nExample answers:\n- Me despierto a las siete.\n- Sí, me ducho por la mañana.\n- Me cepillo los dientes después de desayunar.\n- Me visto antes de desayunar.", + "vocab": [ + {"lemma": "despertarse", "pos": "VERB"}, + {"lemma": "ducharse", "pos": "VERB"}, + {"lemma": "cepillarse los dientes", "pos": "VERB"}, + {"lemma": "vestirse", "pos": "VERB"}, + {"lemma": "desayunar", "pos": "VERB"}, + {"lemma": "antes", "pos": "ADV"}, + {"lemma": "después", "pos": "ADV"}, + {"lemma": "por la mañana", "pos": "PHRASE"}, + {"lemma": "a las siete", "pos": "PHRASE"}, + {"lemma": "¿A qué hora...?", "pos": "PHRASE"}, + ], + "roles": [ + { + "name": "Interviewer", + "id": "fd79397e-729f-4df0-a764-9efa7d1670e4", + }, + { + "name": "Interviewee", + "id": "0e183824-50c8-47dd-ad05-475c69b566ca", + } + ], + "req": { + "topic": "Interview about your morning routine", + "mode": "Conversation", + "objective": + "I can ask and answer questions using reflexive verbs and time expressions to describe my partner’s morning routine.", + "media": "nan", + "activity_cefr_level": "A1", + "language_of_instructions": "en", + "target_language": "es", + "number_of_participants": 2, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "431MITEoFITdF9MHr8MnbCvNrMMrNm3kWexk", + }, + { + "title": "Let's Plan Our Ideal Day!", + "learning_objective": + "Collaborate to create a schedule for a day trip, using reflexive verbs and time expressions.", + "instructions": + "You will work together to plan an ideal day trip. Each of you will choose an activity (using the images provided) and decide what time you will do it. Use Spanish reflexive verbs and time expressions in your sentences. For example: \"A las ocho, me levanto.\" or \"Después, nos bañamos en la playa.\" Send an image of your chosen activity and write your sentence in Spanish. Discuss and agree on the schedule as a group.", + "vocab": [ + {"lemma": "levantarse", "pos": "VERB"}, + {"lemma": "desayunarse", "pos": "VERB"}, + {"lemma": "irse", "pos": "VERB"}, + {"lemma": "bañarse", "pos": "VERB"}, + {"lemma": "acostarse", "pos": "VERB"}, + {"lemma": "a las ocho", "pos": "PHRASE"}, + {"lemma": "después", "pos": "ADV"}, + {"lemma": "por la tarde", "pos": "PHRASE"}, + {"lemma": "por la mañana", "pos": "PHRASE"}, + ], + "roles": [ + { + "name": "Organizador/a", + "id": "03591385-9917-4c9a-8d30-1da5bcfb877e", + }, + { + "name": "Fotógrafo/a", + "id": "39ff6249-618c-4873-9547-225ff4d57390", + }, + { + "name": "Cronista", + "id": "0e464cf9-9bd6-4c39-a3a7-bf31178cae08", + } + ], + "req": { + "topic": "Planning an ideal day", + "mode": "Decision Making", + "objective": + "We can collaborate to create a schedule for a day trip, using reflexive verbs and time expressions to decide on activities and times.", + "media": "images", + "activity_cefr_level": "A1", + "language_of_instructions": "en", + "target_language": "es", + "number_of_participants": 3, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "OW8MPe2xgCHbQZsYbhFZWDn5bIaFsiI74eKq", + }, + { + "title": + "Guess My Daily Action: 20-Question Reflexive Verbs Game", + "learning_objective": + "I can ask and answer yes/no questions with reflexive verbs to guess my partner’s chosen daily routine action.", + "instructions": + "One of you chooses a daily routine action (for example: \"me levanto\" - I get up). The other person asks yes/no questions in Spanish using reflexive verbs to guess the action. You can ask up to 20 questions. Use simple questions like: \"¿Te duchas por la mañana?\" (Do you shower in the morning?), \"¿Te acuestas tarde?\" (Do you go to bed late?). The responder only answers with \"sí\" or \"no\". Try to guess the action before reaching 20 questions!", + "vocab": [ + {"lemma": "levantarse", "pos": "VERB"}, + {"lemma": "ducharse", "pos": "VERB"}, + {"lemma": "acostarse", "pos": "VERB"}, + {"lemma": "despertarse", "pos": "VERB"}, + {"lemma": "cepillarse los dientes", "pos": "VERB"}, + {"lemma": "vestirse", "pos": "VERB"}, + {"lemma": "peinarse", "pos": "VERB"}, + {"lemma": "lavarse la cara", "pos": "VERB"}, + ], + "roles": [ + { + "name": "Questioner", + "id": "61eac67e-24a7-42d6-bc2f-f84bd6c0a534", + }, + { + "name": "Responder", + "id": "f85a9ae2-f3dc-4c48-a4fc-2e693c368c25", + } + ], + "req": { + "topic": "Guess my daily action", + "mode": "20-Question Game", + "objective": + "I can ask and answer yes/no questions with reflexive verbs to guess my partner’s chosen daily routine action.", + "media": "nan", + "activity_cefr_level": "A1", + "language_of_instructions": "en", + "target_language": "es", + "number_of_participants": 2, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "BSkca8xTk3KOF23vGlQgYJRm7ZJdUX1LxwsA", + }, + { + "title": "Time Expression Scavenger Hunt (Voice Edition)", + "learning_objective": + "We can locate and sequence hidden voice-recorded routine descriptions, then play back and identify the corresponding time expressions.", + "instructions": + "You will each have a role. The Routine Reader will record a short voice message describing a daily routine, using a time expression in Spanish (e.g., \"A las ocho de la mañana, desayuno\"). The Time Hunters will listen to the messages, locate the routines, and write down the time expressions they hear. After all routines are found, listen again and sequence the routines in order from earliest to latest. Example time expressions: \"a las siete\", \"por la tarde\", \"después de cenar\". At the end, share the list of time expressions in order. ¡Buena suerte!", + "vocab": [ + {"lemma": "a las ocho", "pos": "ADV"}, + {"lemma": "por la mañana", "pos": "ADV"}, + {"lemma": "por la tarde", "pos": "ADV"}, + {"lemma": "después de", "pos": "ADV"}, + {"lemma": "antes de", "pos": "ADV"}, + {"lemma": "desayunar", "pos": "VERB"}, + {"lemma": "cenar", "pos": "VERB"}, + {"lemma": "levantarse", "pos": "VERB"}, + ], + "roles": [ + { + "name": "Routine Reader", + "id": "cc4d136f-a27d-4549-9b9e-77fd2e7625a8", + }, + { + "name": "Time Hunter", + "id": "17014cbc-093d-48e4-b9a2-dd8901a28628", + }, + { + "name": "Time Hunter", + "id": "85d5fab1-6591-4ce2-9518-6f6e44a4c740", + } + ], + "req": { + "topic": "Time expression scavenger hunt", + "mode": "Scavenger Hunt", + "objective": + "We can locate and sequence hidden voice-recorded routine descriptions, then play back and identify the corresponding time expressions.", + "media": "voice_messages", + "activity_cefr_level": "A1", + "language_of_instructions": "en", + "target_language": "es", + "number_of_participants": 3, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "zDUiSpsHD9ivlwyB8Qsz9yjZQQyrQOMCVTIh", + }, + { + "title": "Early Bird vs Night Owl Debate", + "learning_objective": + "I can present and defend my preference for morning or evening routines, using reflexive verbs and time expressions to support my argument.", + "instructions": + "Each of you will have a role in this debate. Two of you will argue for being an 'early bird' (morning person), and two will argue for being a 'night owl' (evening person). Use simple sentences with reflexive verbs and time expressions to support your opinion. Listen to others and respond with a short argument. \n\nExample phrases:\n- Me despierto a las seis de la mañana porque me gusta la mañana.\n- Prefiero la noche porque me acuesto tarde y estudio mejor.\n- ¿Por qué te gusta la mañana/la noche?\n- Yo también me levanto temprano/tarde.\n\nTake turns speaking. Use at least two reflexive verbs and two time expressions in your argument.", + "vocab": [ + {"lemma": "despertarse", "pos": "VERB"}, + {"lemma": "levantarse", "pos": "VERB"}, + {"lemma": "acostarse", "pos": "VERB"}, + {"lemma": "ducharse", "pos": "VERB"}, + {"lemma": "por la mañana", "pos": "PHRASE"}, + {"lemma": "por la noche", "pos": "PHRASE"}, + {"lemma": "temprano", "pos": "ADV"}, + {"lemma": "tarde", "pos": "ADV"}, + {"lemma": "siempre", "pos": "ADV"}, + {"lemma": "nunca", "pos": "ADV"}, + ], + "roles": [ + { + "name": "Early Bird 1", + "id": "c43cfda4-5fa7-4c92-a78c-8f8c04ca93d7", + }, + { + "name": "Early Bird 2", + "id": "ed15628a-1be2-4589-8e31-192821306642", + }, + { + "name": "Night Owl 1", + "id": "b131421b-374d-44c9-92fd-511978b0aa0d", + }, + { + "name": "Night Owl 2", + "id": "9b5eef80-c8cc-466f-bc23-8c34d31779af", + } + ], + "req": { + "topic": "Early bird vs night owl debate", + "mode": "Debate", + "objective": + "I can present and defend my preference for morning or evening routines, using reflexive verbs and time expressions to support my argument.", + "media": "nan", + "activity_cefr_level": "A1", + "language_of_instructions": "en", + "target_language": "es", + "number_of_participants": 4, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "gyuXS2vRlG9WJhYNkYk5Xc39L95rCSlo6xKz", + } + ], + }, + { + "title": "Capítulo 9: La ciudad y el transporte", + "description": + "Discover places in town (el banco, la parada de autobús, la estación). Learn modes of transport (el autobús, el metro). Ask for directions: ¿Dónde está la farmacia? Gira a la derecha.", + "uuid": "097f5e82-93ff-4e9e-ad70-5fb21664f1a7", + "activity_ids": [], + "activities": [ + { + "title": "Lost in the City: Asking for Directions", + "learning_objective": + "I can ask for and give directions using simple phrases like ¿Dónde está la farmacia? and Gira a la derecha.", + "instructions": + "You will roleplay a situation where one of you is lost and needs to find a place in the city. \n\nRole 1: You are looking for a place (for example, la farmacia, el banco, el restaurante). Ask questions in Spanish, such as:\n- ¿Dónde está la farmacia?\n- ¿Cómo llego al banco?\n\nRole 2: You are a local resident. Answer the questions using simple Spanish directions, such as:\n- Gira a la derecha.\n- Sigue recto.\n- Está cerca/lejos.\n\nUse the target vocabulary below to help you. Take turns asking and answering at least 3 questions.", + "vocab": [ + {"lemma": "¿Dónde está...?", "pos": "PHRASE"}, + {"lemma": "Gira a la derecha", "pos": "PHRASE"}, + {"lemma": "Gira a la izquierda", "pos": "PHRASE"}, + {"lemma": "Sigue recto", "pos": "PHRASE"}, + {"lemma": "Está cerca", "pos": "PHRASE"}, + {"lemma": "Está lejos", "pos": "PHRASE"}, + {"lemma": "la farmacia", "pos": "NOUN"}, + {"lemma": "el banco", "pos": "NOUN"}, + {"lemma": "el restaurante", "pos": "NOUN"}, + {"lemma": "la calle", "pos": "NOUN"}, + ], + "roles": [ + { + "name": "Lost Person", + "id": "305d4249-d62a-47a6-a311-6b013fa1cc1f", + }, + { + "name": "Local Resident", + "id": "a75f0411-c1ca-45ef-ac11-040f1a7e3022", + } + ], + "req": { + "topic": "Asking for directions", + "mode": "Roleplay", + "objective": + "I can ask for and give directions using phrases like ¿Dónde está la farmacia? and Gira a la derecha.", + "media": "nan", + "activity_cefr_level": "A1", + "language_of_instructions": "en", + "target_language": "es", + "number_of_participants": 2, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "OI0d2RE0zPWsqnLuaHiircQ7wmnItnN6zRLs", + }, + { + "title": "Town Map Scavenger Hunt", + "learning_objective": + "I can locate and name common places in a town using visual clues on a map or images.", + "instructions": + "1. The Clue Giver will send an image of a place in town (for example, a photo or drawing of a library, school, or park) to the group.\n2. The Guessers will look at the image and use Spanish to guess what place it is. Use the phrase: \"¿Es la/el [place]?\" (e.g., \"¿Es la biblioteca?\")\n3. The Clue Giver will respond with \"Sí, es la/el [place]\" or \"No, no es la/el [place]\" until someone guesses correctly.\n4. Repeat with new images for more practice.", + "vocab": [ + {"lemma": "biblioteca", "pos": "NOUN"}, + {"lemma": "escuela", "pos": "NOUN"}, + {"lemma": "parque", "pos": "NOUN"}, + {"lemma": "supermercado", "pos": "NOUN"}, + {"lemma": "hospital", "pos": "NOUN"}, + {"lemma": "restaurante", "pos": "NOUN"}, + {"lemma": "iglesia", "pos": "NOUN"}, + {"lemma": "estación", "pos": "NOUN"}, + {"lemma": "farmacia", "pos": "NOUN"}, + {"lemma": "cine", "pos": "NOUN"}, + ], + "roles": [ + { + "name": "Clue Giver", + "id": "e5601327-2b9e-4a6f-875c-77068ca133cb", + }, + { + "name": "Guesser", + "id": "d5fd0ff1-0393-4224-875d-26d743032208", + }, + { + "name": "Guesser", + "id": "fb5839eb-d00e-4edf-82dc-3fa4655488a3", + } + ], + "req": { + "topic": "Identifying places in town", + "mode": "Scavenger Hunt", + "objective": + "I can locate and name common places in a town using visual clues on a map or images.", + "media": "images", + "activity_cefr_level": "A1", + "language_of_instructions": "en", + "target_language": "es", + "number_of_participants": 3, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "Vtmn6LzlpMOiWHintG13cD9uCIjQVfH9w8Fu", + }, + { + "title": "Transport Preferences Voice Chat", + "learning_objective": + "I can talk about different modes of transport (autobús, metro, taxi) and express my preferences using 'prefiero' and 'me gusta'.", + "instructions": + "Each of you will record a voice message. The Questioner will ask the Responder which transport they prefer and why, using the phrases '¿Prefieres viajar en autobús, metro o taxi? ¿Por qué?' The Responder will reply, using 'Prefiero...' or 'Me gusta...' and give a reason. Example: 'Prefiero viajar en metro porque es rápido.'", + "vocab": [ + {"lemma": "autobús", "pos": "NOUN"}, + {"lemma": "metro", "pos": "NOUN"}, + {"lemma": "taxi", "pos": "NOUN"}, + {"lemma": "prefiero", "pos": "VERB"}, + {"lemma": "me gusta", "pos": "VERB"}, + ], + "roles": [ + { + "name": "Questioner", + "id": "8c124d28-c0c0-4d95-99b6-4d50b2a55200", + }, + { + "name": "Responder", + "id": "f5a61d1c-b359-4eb5-9fd9-e651e607895b", + } + ], + "req": { + "topic": "Discussing modes of transport", + "mode": "Conversation", + "objective": + "I can talk about different modes of transport (autobús, metro, taxi) and express my preferences using prefiero and me gusta.", + "media": "voice_messages", + "activity_cefr_level": "A1", + "language_of_instructions": "en", + "target_language": "es", + "number_of_participants": 2, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "vR7Eg62tle2CcO2UHvboWCLjuRTcnRoJip5U", + }, + { + "title": "20-Question Game: Guess the Place in Town", + "learning_objective": + "I can ask and answer yes/no questions to guess a place in town using key vocabulary.", + "instructions": + "One of you will think of a place in town (for example: banco, escuela, supermercado) and keep it secret. The other will ask yes/no questions in Spanish to try to guess the place. You can ask up to 20 questions. Use phrases like: ¿Es grande? ¿Está cerca del parque? ¿Venden comida allí? The responder answers only with 'sí' or 'no'. When you are ready, guess the place!", + "vocab": [ + {"lemma": "banco", "pos": "NOUN"}, + {"lemma": "escuela", "pos": "NOUN"}, + {"lemma": "supermercado", "pos": "NOUN"}, + {"lemma": "parque", "pos": "NOUN"}, + {"lemma": "restaurante", "pos": "NOUN"}, + {"lemma": "farmacia", "pos": "NOUN"}, + {"lemma": "hospital", "pos": "NOUN"}, + {"lemma": "cine", "pos": "NOUN"}, + {"lemma": "tienda", "pos": "NOUN"}, + {"lemma": "iglesia", "pos": "NOUN"}, + {"lemma": "¿Es...?", "pos": "PHRASE"}, + {"lemma": "¿Está...?", "pos": "PHRASE"}, + {"lemma": "¿Venden...?", "pos": "PHRASE"}, + {"lemma": "sí", "pos": "ADVERB"}, + {"lemma": "no", "pos": "ADVERB"}, + ], + "roles": [ + { + "name": "Questioner", + "id": "ab4ae4c4-00fd-41c4-a151-b14c8db4e9aa", + }, + { + "name": "Responder", + "id": "588792f3-a32b-4feb-915d-f024714ffa4b", + } + ], + "req": { + "topic": "Guess the place in town", + "mode": "20-Question Game", + "objective": + "I can ask and answer yes/no questions to guess a place in town using key vocabulary.", + "media": "nan", + "activity_cefr_level": "A1", + "language_of_instructions": "en", + "target_language": "es", + "number_of_participants": 2, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "XKfXQDlqVGvxM8RtfBYkYx3WAM6IARP4dHbm", + }, + { + "title": "Choosing the Best Route Through Town", + "learning_objective": + "I can collaborate to choose the best transport route through town, justifying decisions with directional language.", + "instructions": + "Work together to decide the best way to travel from the park to the museum. Use Spanish to suggest, ask, and justify your choices. Each person has a role: one asks questions, one gives suggestions, and one gives reasons. Use phrases like: \"¿Vamos en autobús o a pie?\", \"Prefiero ir en metro porque es rápido.\", \"Creo que a pie es mejor porque está cerca.\" Decide together on the best route and explain why.", + "vocab": [ + {"lemma": "autobús", "pos": "NOUN"}, + {"lemma": "metro", "pos": "NOUN"}, + {"lemma": "a pie", "pos": "ADV"}, + {"lemma": "rápido", "pos": "ADJ"}, + {"lemma": "cerca", "pos": "ADV"}, + {"lemma": "lejos", "pos": "ADV"}, + {"lemma": "¿Vamos...?", "pos": "PHRASE"}, + {"lemma": "Prefiero", "pos": "VERB"}, + {"lemma": "porque", "pos": "CONJ"}, + {"lemma": "mejor", "pos": "ADJ"}, + ], + "roles": [ + { + "name": "Questioner", + "id": "349a355f-275d-416c-adef-19d62db5a17e", + }, + { + "name": "Suggestion Giver", + "id": "e8e2c06c-2b29-4def-a534-1e537def4ee8", + }, + { + "name": "Justifier", + "id": "a7d3fee9-098f-4b95-83f8-b4df4b51de20", + } + ], + "req": { + "topic": "Planning a route", + "mode": "Decision Making", + "objective": + "I can collaborate to choose the best transport route through town, justifying decisions with directional language.", + "media": "nan", + "activity_cefr_level": "A1", + "language_of_instructions": "en", + "target_language": "es", + "number_of_participants": 3, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "VSNrdZGNNHB8l8eztO4hA2Yj6Sl05obf0N1F", + } + ], + }, + { + "title": "Capítulo 10: Tiempo libre y pasatiempos", + "description": + "Talk about hobbies and free-time activities (leer, escuchar música, hacer deporte). Use the present progressive (estoy leyendo, estáis escuchando). Express likes/dislikes: No me gusta correr.", + "uuid": "90fd6187-929e-4100-87f8-4bcbf5e717b8", + "activity_ids": [], + "activities": [ + { + "title": "What Are You Doing Now?", + "learning_objective": + "I can ask and tell what I and my partner are doing right now using the present progressive.", + "instructions": + "You will have a conversation about what you are doing right now in your free time. One of you will ask questions, and the other will answer using the present progressive (estar + gerundio). Use the vocabulary list below. Example question: ¿Qué estás haciendo ahora? Example answer: Estoy leyendo un libro. Then, switch roles for more practice.", + "vocab": [ + {"lemma": "leer", "pos": "VERB"}, + {"lemma": "escuchar música", "pos": "VERB"}, + {"lemma": "ver la televisión", "pos": "VERB"}, + {"lemma": "jugar videojuegos", "pos": "VERB"}, + {"lemma": "dibujar", "pos": "VERB"}, + {"lemma": "bailar", "pos": "VERB"}, + {"lemma": "cantar", "pos": "VERB"}, + {"lemma": "cocinar", "pos": "VERB"}, + ], + "roles": [ + { + "name": "Questioner", + "id": "34047a76-3220-4e2a-9902-33b02a8900f4", + }, + { + "name": "Responder", + "id": "ce98c001-4ed8-40c7-b356-df5a1591d4fc", + } + ], + "req": { + "topic": "Describing current free-time activities", + "mode": "Conversation", + "objective": + "I can ask and tell what I and my partner are doing right now using the present progressive.", + "media": "nan", + "activity_cefr_level": "A1", + "language_of_instructions": "en", + "target_language": "es", + "number_of_participants": 2, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "HSIDClSlb4ODswApS62Un41Gnextqlunr4s7", + }, + { + "title": "Guess the Hobby: 20-Question Game with Images", + "learning_objective": + "I can ask yes/no questions in Spanish to identify free-time activities and express likes/dislikes.", + "instructions": + "1. Responder: Choose a picture of a hobby or free-time activity (for example, someone playing soccer, reading, or painting) and send it in the chat without saying the activity name.\n2. Questioner: Ask yes/no questions in Spanish to guess what the hobby is. Use simple questions like:\n- ¿Te gusta este pasatiempo?\n- ¿Es un deporte?\n- ¿Necesitas libros para hacerlo?\n- ¿Lo haces en casa?\nResponder, answer only with 'sí' or 'no.'\n3. The Questioner can ask up to 20 questions. Try to guess the activity before reaching 20 questions!\n4. When you are ready, say your guess in Spanish (for example: \"¿Es leer?\").\n5. Express if you like or dislike the hobby using: \"Me gusta...\" or \"No me gusta...\"", + "vocab": [ + {"lemma": "pasatiempo", "pos": "NOUN"}, + {"lemma": "deporte", "pos": "NOUN"}, + {"lemma": "leer", "pos": "VERB"}, + {"lemma": "jugar", "pos": "VERB"}, + {"lemma": "pintar", "pos": "VERB"}, + {"lemma": "bailar", "pos": "VERB"}, + {"lemma": "cantar", "pos": "VERB"}, + {"lemma": "ver la televisión", "pos": "VERB"}, + {"lemma": "escuchar música", "pos": "VERB"}, + {"lemma": "Me gusta", "pos": "PHRASE"}, + {"lemma": "No me gusta", "pos": "PHRASE"}, + {"lemma": "¿Te gusta...?", "pos": "PHRASE"}, + {"lemma": "¿Es...?", "pos": "PHRASE"}, + ], + "roles": [ + { + "name": "Questioner", + "id": "dd5a4efb-6e17-4c09-bf87-145a3fe4aa4c", + }, + { + "name": "Responder", + "id": "24c46b57-4f32-4c92-a84c-1f86121377d2", + } + ], + "req": { + "topic": "Guess the Hobby", + "mode": "20-Question Game", + "objective": + "I can ask yes/no questions in Spanish to identify free-time activities and express likes/dislikes.", + "media": "images", + "activity_cefr_level": "A1", + "language_of_instructions": "en", + "target_language": "es", + "number_of_participants": 2, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "ukQJviXpZPjO65erOczvOI8rWti4VEF4Ax77", + }, + { + "title": "Let's Invite a Friend! (Present Progressive Roleplay)", + "learning_objective": + "I can use the present progressive to invite someone to do a hobby and respond with preferences.", + "instructions": + "You will each have a role. Use voice messages to act out the situation in Spanish. \n\nRole 1: Invite a friend to do a hobby using the present progressive (e.g., \"¿Estás jugando al fútbol? ¿Quieres venir a jugar conmigo?\").\nRole 2: Respond, say if you like or don't like the activity, and suggest another if you want (e.g., \"No, no me gusta jugar al fútbol. Estoy leyendo un libro. ¿Quieres leer conmigo?\").\nRole 3: Listen to both and say which activity you prefer to do (e.g., \"Prefiero jugar al fútbol.\").\n\nSpeak slowly and clearly. Use the example phrases to help you.", + "vocab": [ + {"lemma": "estoy", "pos": "VERB"}, + {"lemma": "estás", "pos": "VERB"}, + {"lemma": "jugando", "pos": "VERB"}, + {"lemma": "leyendo", "pos": "VERB"}, + {"lemma": "comiendo", "pos": "VERB"}, + {"lemma": "bailando", "pos": "VERB"}, + {"lemma": "me gusta", "pos": "VERB"}, + {"lemma": "no me gusta", "pos": "VERB"}, + {"lemma": "prefiero", "pos": "VERB"}, + {"lemma": "¿Quieres...?", "pos": "PHRASE"}, + ], + "roles": [ + { + "name": "Inviter", + "id": "09e59863-1aab-49ad-b082-354470c6661c", + }, + { + "name": "Responder", + "id": "cb46c7a5-6d65-4161-911b-eaa55cf1d7a0", + }, + { + "name": "Decider", + "id": "edcae5ad-6b30-451e-80d9-a3116dca5881", + } + ], + "req": { + "topic": "Inviting a friend to an activity", + "mode": "Roleplay", + "objective": + "I can use the present progressive to invite someone to do a hobby and respond with preferences.", + "media": "voice_messages", + "activity_cefr_level": "A1", + "language_of_instructions": "en", + "target_language": "es", + "number_of_participants": 3, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "ZrwcZ7EeGhONMSl3S58o64eFG3PrONNjCitP", + }, + { + "title": "Let's Plan Our Weekend!", + "learning_objective": + "I can negotiate and agree on free-time plans using likes/dislikes and the present progressive.", + "instructions": + "Work together to decide what you will do this weekend. Each person will share what they like or don’t like, and suggest an activity using the present progressive (for example: Estoy pensando en ir al cine). Use phrases like: Me gusta..., No me gusta..., Estoy pensando en..., ¿Qué piensas tú? Try to agree on one plan for everyone!", + "vocab": [ + {"lemma": "gustar", "pos": "VERB"}, + {"lemma": "pensar", "pos": "VERB"}, + {"lemma": "ir", "pos": "VERB"}, + {"lemma": "cine", "pos": "NOUN"}, + {"lemma": "parque", "pos": "NOUN"}, + {"lemma": "comer", "pos": "VERB"}, + {"lemma": "bailar", "pos": "VERB"}, + {"lemma": "leer", "pos": "VERB"}, + {"lemma": "música", "pos": "NOUN"}, + {"lemma": "amigos", "pos": "NOUN"}, + {"lemma": "fin de semana", "pos": "NOUN"}, + ], + "roles": [ + { + "name": "Planner 1", + "id": "d9f363bc-e36b-4add-9b43-0cb24cdf5378", + }, + { + "name": "Planner 2", + "id": "8b39964e-5233-4b33-930b-ee0366b6b93c", + }, + { + "name": "Planner 3", + "id": "363835e4-e947-47db-88c4-518268e2194f", + } + ], + "req": { + "topic": "Planning a weekend plan", + "mode": "Decision Making", + "objective": + "I can negotiate and agree on free-time plans using likes/dislikes and the present progressive.", + "media": "nan", + "activity_cefr_level": "A1", + "language_of_instructions": "en", + "target_language": "es", + "number_of_participants": 3, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "GubXruzJcw6wku5qt47EstiCPCVukJet3r8e", + }, + { + "title": "Hobby Scavenger Hunt: Present Progressive Edition", + "learning_objective": + "I can find and describe items or pictures related to hobbies using the present progressive and share my opinions.", + "instructions": + "Each of you will have a different role. The Questioner asks for an item or picture related to a hobby (for example: \"¿Puedes encontrar algo para leer?\"). The Finder searches for the item or picture and describes what someone is doing with it using the present progressive (for example: \"La persona está leyendo un libro.\"). The Opinion Giver listens to the description and shares their opinion using a simple phrase (for example: \"Me gusta leer\" or \"No me gusta leer\"). The Encourager gives positive feedback (for example: \"¡Muy bien!\" or \"Excelente descripción!\"). Then, move to the next round with new items. Use the example phrases to help you!", + "vocab": [ + {"lemma": "leer", "pos": "VERB"}, + {"lemma": "jugar", "pos": "VERB"}, + {"lemma": "dibujar", "pos": "VERB"}, + {"lemma": "bailar", "pos": "VERB"}, + {"lemma": "escuchar música", "pos": "VERB"}, + {"lemma": "viendo", "pos": "VERB"}, + {"lemma": "haciendo ejercicio", "pos": "VERB"}, + {"lemma": "me gusta", "pos": "PHRASE"}, + {"lemma": "no me gusta", "pos": "PHRASE"}, + {"lemma": "está", "pos": "VERB"}, + {"lemma": "libro", "pos": "NOUN"}, + {"lemma": "película", "pos": "NOUN"}, + {"lemma": "música", "pos": "NOUN"}, + ], + "roles": [ + { + "name": "Questioner", + "id": "50f9e70f-c06b-4cb7-b596-e47ce85d9f7f", + }, + { + "name": "Finder", + "id": "f36ebe8d-5cf9-4ddd-8bed-1de2df61fec8", + }, + { + "name": "Opinion Giver", + "id": "4aafe0a5-880c-49a9-8480-29f8f004ac38", + }, + { + "name": "Encourager", + "id": "dae6e36b-57d6-4bf8-b815-862d2461dee8", + } + ], + "req": { + "topic": "Hobby scavenger hunt", + "mode": "Scavenger Hunt", + "objective": + "I can find and describe items or pictures related to hobbies using the present progressive and share my opinions.", + "media": "nan", + "activity_cefr_level": "A1", + "language_of_instructions": "en", + "target_language": "es", + "number_of_participants": 4, + "include_image": false, + "save_to_db": false, + "count": 1, + }, + "activity_id": "wea1f38vAlmmAfdMxBDMyNbFB5QM2GcITFef", + } + ], + } + ], + } + ], +}; diff --git a/lib/pangea/events/constants/pangea_event_types.dart b/lib/pangea/events/constants/pangea_event_types.dart index 795e5db70..56564218c 100644 --- a/lib/pangea/events/constants/pangea_event_types.dart +++ b/lib/pangea/events/constants/pangea_event_types.dart @@ -47,6 +47,9 @@ class PangeaEventTypes { /// Profile information related to a user's analytics static const profileAnalytics = "pangea.analytics_profile"; static const profileActivities = "pangea.activities_profile"; - static const activityRoomIds = "pangea.activity_room_ids"; + + /// Relates to course plans + static const coursePlan = "pangea.course_plan"; + static const courseUser = "p.course_user"; } diff --git a/lib/pangea/extensions/room_children_and_parents_extension.dart b/lib/pangea/extensions/room_children_and_parents_extension.dart index 1b669f4f6..81ca6ba3c 100644 --- a/lib/pangea/extensions/room_children_and_parents_extension.dart +++ b/lib/pangea/extensions/room_children_and_parents_extension.dart @@ -1,6 +1,15 @@ part of "pangea_room_extension.dart"; extension ChildrenAndParentsRoomExtension on Room { + Room? get firstSpaceParent { + for (final parent in spaceParents) { + if (parent.roomId == null) continue; + final room = client.getRoomById(parent.roomId!); + if (room != null) return room; + } + return null; + } + List get pangeaSpaceParents => client.rooms .where( (r) => r.isSpace, @@ -37,22 +46,10 @@ extension ChildrenAndParentsRoomExtension on Room { } } - try { - await _trySetSpaceChild( - roomId, - suggested: suggested, - ); - } catch (err, stack) { - ErrorHandler.logError( - e: err, - s: stack, - data: { - "roomID": roomId, - "childID": child.id, - "suggested": suggested, - }, - ); - } + await _trySetSpaceChild( + roomId, + suggested: suggested, + ); } Future _trySetSpaceChild( diff --git a/lib/pangea/find_your_people/find_your_people_view.dart b/lib/pangea/find_your_people/find_your_people_view.dart index a6d81d57f..050079362 100644 --- a/lib/pangea/find_your_people/find_your_people_view.dart +++ b/lib/pangea/find_your_people/find_your_people_view.dart @@ -64,16 +64,14 @@ class FindYourPeopleView extends StatelessWidget { ), ], ), - floatingActionButton: isColumnMode - ? null - : FloatingActionButton.extended( - onPressed: () => context.push('/rooms/newspace'), - icon: const Icon(Icons.add_box_outlined), - label: Text( - L10n.of(context).space, - overflow: TextOverflow.fade, - ), - ), + floatingActionButton: FloatingActionButton.extended( + onPressed: () => context.push('/rooms/communities/newcourse'), + icon: const Icon(Icons.add_box_outlined), + label: Text( + L10n.of(context).newCourse, + overflow: TextOverflow.fade, + ), + ), body: Padding( padding: isColumnMode ? const EdgeInsets.symmetric( @@ -194,30 +192,6 @@ class FindYourPeopleView extends StatelessWidget { context, ), ), - TextButton( - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.add_box_outlined, - color: theme - .colorScheme.onPrimaryContainer, - size: 24.0, - ), - const SizedBox(width: 8.0), - Text( - L10n.of(context).createYourSpace, - style: TextStyle( - color: theme - .colorScheme.onPrimaryContainer, - fontSize: 16.0, - ), - ), - ], - ), - onPressed: () => - context.push('/rooms/newspace'), - ), ], ), ], diff --git a/lib/pangea/public_spaces/public_room_bottom_sheet.dart b/lib/pangea/public_spaces/public_room_bottom_sheet.dart index a634dc39b..bd90793db 100644 --- a/lib/pangea/public_spaces/public_room_bottom_sheet.dart +++ b/lib/pangea/public_spaces/public_room_bottom_sheet.dart @@ -40,7 +40,7 @@ class PublicRoomBottomSheet extends StatefulWidget { .getRoomById(chunk!.roomId); if (room != null && room.membership == Membership.join) { - context.go("/rooms?spaceId=${room.id}"); + context.go("/rooms/spaces/${room.id}/details"); return null; } @@ -100,7 +100,7 @@ class PublicRoomBottomSheetState extends State { if (chunk?.roomType != 'm.space' && !client.getRoomById(roomID)!.isSpace) { outerContext.go("/rooms/$roomID"); } else { - context.go('/rooms?spaceId=$roomID'); + context.go('/rooms/spaces/$roomID/details'); } } diff --git a/lib/pangea/space_analytics/space_analytics_view.dart b/lib/pangea/space_analytics/space_analytics_view.dart index 8c9d0c09f..8d64cec5b 100644 --- a/lib/pangea/space_analytics/space_analytics_view.dart +++ b/lib/pangea/space_analytics/space_analytics_view.dart @@ -1,4 +1,3 @@ -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:collection/collection.dart'; @@ -24,289 +23,272 @@ class SpaceAnalyticsView extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); - return Scaffold( - appBar: AppBar( - title: Text(L10n.of(context).spaceAnalytics), - centerTitle: true, - ), - body: LayoutBuilder( - builder: (context, constraints) { - final mini = constraints.maxWidth <= 550; - return Padding( - padding: EdgeInsets.all(!mini ? 16.0 : 8.0), - child: MaxWidthBody( - maxWidth: 1000, - showBorder: false, - child: Column( - spacing: !mini ? 24.0 : 12.0, + return LayoutBuilder( + builder: (context, constraints) { + final mini = constraints.maxWidth <= 550; + return MaxWidthBody( + maxWidth: 1000, + showBorder: false, + child: Column( + spacing: !mini ? 24.0 : 12.0, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + spacing: !mini ? 12.0 : 4.0, children: [ - Row( - spacing: !mini ? 12.0 : 4.0, - children: [ - _MenuButton( - text: L10n.of(context).requestAll, - icon: Symbols.approval_delegation, - onPressed: controller.requestAllAnalytics, - mini: mini, - hideLabel: false, - ), - if (controller.room != null && - controller.availableAnalyticsRooms.isNotEmpty && - kIsWeb) - _MenuButton( - text: L10n.of(context).download, - icon: Icons.download, - onPressed: () { - showDialog( - context: context, - builder: (context) => DownloadAnalyticsDialog( - space: controller.room!, - analyticsRooms: - controller.availableAnalyticsRooms, - ), - ); - }, - mini: mini, - ), - ], + _MenuButton( + text: L10n.of(context).requestAll, + icon: Symbols.approval_delegation, + onPressed: controller.requestAllAnalytics, + mini: mini, + hideLabel: false, ), - Row( - spacing: !mini ? 12.0 : 4.0, - children: [ - if (controller.lastUpdatedString != null) - Text( - L10n.of(context).lastUpdated( - controller.lastUpdatedString!, + if (controller.room != null && + controller.availableAnalyticsRooms.isNotEmpty) + _MenuButton( + text: L10n.of(context).download, + icon: Icons.download, + onPressed: () { + showDialog( + context: context, + builder: (context) => DownloadAnalyticsDialog( + space: controller.room!, + analyticsRooms: + controller.availableAnalyticsRooms, ), - textAlign: TextAlign.end, - style: TextStyle( - fontSize: !mini ? 12.0 : 8.0, - color: theme.disabledColor, - ), - ), - _MenuButton( - text: L10n.of(context).refresh, - icon: Symbols.refresh, - onPressed: controller.refresh, - mini: mini, - ), - DropdownButtonHideUnderline( - child: DropdownButton2( - customButton: Container( - height: !mini ? 36.0 : 26.0, - decoration: BoxDecoration( - color: theme.colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(40), - ), - padding: EdgeInsets.symmetric( - horizontal: !mini ? 8.0 : 4.0, - vertical: 4.0, - ), - child: Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - if (controller.selectedLanguage != null) - Text( - controller.selectedLanguage!.langCode - .toUpperCase(), - style: TextStyle( - color: theme - .colorScheme.onPrimaryContainer, - fontSize: !mini ? 16.0 : 12.0, - ), - ), - Icon( - Icons.arrow_drop_down, - color: - theme.colorScheme.onPrimaryContainer, - size: !mini ? 24.0 : 14.0, - ), - ], - ), - ), - value: controller.selectedLanguage, - items: controller.availableLanguages - .map( - (item) => DropdownMenuItem( - value: item, - child: DropdownTextButton( - text: item.getDisplayName(context) ?? - item.displayName, - isSelected: false, - ), - ), - ) - .toList(), - onChanged: controller.setSelectedLanguage, - buttonStyleData: ButtonStyleData( - // This is necessary for the ink response to match our customButton radius. - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(40), - ), - ), - dropdownStyleData: const DropdownStyleData( - offset: Offset(-160, 0), - width: 250, - ), - ), - ), - ], - ), + ); + }, + mini: mini, + ), ], ), - controller.initialized - ? Table( - columnWidths: const {0: FlexColumnWidth(2.5)}, - children: [ - TableRow( - decoration: BoxDecoration( - border: Border( - bottom: BorderSide(color: theme.dividerColor), - ), - ), + Row( + spacing: !mini ? 12.0 : 4.0, + children: [ + if (controller.lastUpdatedString != null) + Text( + L10n.of(context).lastUpdated( + controller.lastUpdatedString!, + ), + textAlign: TextAlign.end, + style: TextStyle( + fontSize: !mini ? 12.0 : 8.0, + color: theme.disabledColor, + ), + ), + _MenuButton( + text: L10n.of(context).refresh, + icon: Symbols.refresh, + onPressed: controller.refresh, + mini: mini, + ), + DropdownButtonHideUnderline( + child: DropdownButton2( + customButton: Container( + height: !mini ? 36.0 : 26.0, + decoration: BoxDecoration( + color: theme.colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(40), + ), + padding: EdgeInsets.symmetric( + horizontal: !mini ? 8.0 : 4.0, + vertical: 4.0, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - _TableHeaderCell( - text: L10n.of(context).viewingAnalytics( - controller.completedDownloads, - controller.downloads.length, + if (controller.selectedLanguage != null) + Text( + controller.selectedLanguage! + .getDisplayName(context) ?? + controller + .selectedLanguage!.displayName, + style: TextStyle( + color: + theme.colorScheme.onPrimaryContainer, + fontSize: !mini ? 16.0 : 12.0, + ), ), - icon: Icons.group_outlined, - mini: mini, - ), - _TableHeaderCell( - text: L10n.of(context).level, - icon: Icons.star, - mini: mini, - ), - _TableHeaderCell( - text: L10n.of(context).vocab, - icon: Symbols.dictionary, - mini: mini, - ), - _TableHeaderCell( - text: L10n.of(context).grammar, - icon: Symbols.toys_and_games, - mini: mini, - ), - _TableHeaderCell( - text: L10n.of(context).activities, - icon: Icons.radar, - mini: mini, + Icon( + Icons.arrow_drop_down, + color: theme.colorScheme.onPrimaryContainer, + size: !mini ? 24.0 : 14.0, ), ], ), - ...controller.sortedDownloads.mapIndexed( - (index, entry) { - final download = entry.value; - return TableRow( - children: [ - TableCell( - child: Opacity( - opacity: download.requestStatus.opacity, - child: Padding( - padding: EdgeInsets.symmetric( - vertical: !mini ? 12.0 : 4.0, - ), - child: Row( - spacing: !mini ? 16.0 : 8.0, - children: [ - Avatar( - size: !mini ? 64.0 : 40.0, - mxContent: entry.key.avatarUrl, - name: - entry.key.calcDisplayname(), - userId: entry.key.id, - presenceUserId: entry.key.id, - ), - Flexible( - child: Column( - spacing: 4.0, - mainAxisSize: - MainAxisSize.min, - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - SizedBox( - height: index == 0 - ? 8.0 - : 0.0, - ), - Text( - entry.key.id, - maxLines: 1, - overflow: - TextOverflow.ellipsis, - style: TextStyle( - fontSize: - !mini ? 16.0 : 12.0, - fontWeight: - FontWeight.w500, - ), - ), - _RequestButton( - status: download - .requestStatus, - onPressed: () => - controller - .requestAnalytics( - entry.key, - ), - mini: mini, - ), - const SizedBox(height: 8.0), - ], - ), - ), - ], - ), - ), - ), - ), - _TableContentCell( - text: download.summary?.level?.toString(), - downloadStatus: download.downloadStatus, - requestStatus: download.requestStatus, - mini: mini, - ), - _TableContentCell( - text: download.summary?.numLemmas - .toString(), - downloadStatus: download.downloadStatus, - requestStatus: download.requestStatus, - mini: mini, - ), - _TableContentCell( - text: download.summary?.numMorphConstructs - .toString(), - downloadStatus: download.downloadStatus, - requestStatus: download.requestStatus, - mini: mini, - ), - _TableContentCell( - text: download - .summary?.numCompletedActivities - .toString(), - downloadStatus: download.downloadStatus, - requestStatus: download.requestStatus, - mini: mini, - ), - ], - ); - }, + ), + value: controller.selectedLanguage, + items: controller.availableLanguages + .map( + (item) => DropdownMenuItem( + value: item, + child: DropdownTextButton( + text: item.getDisplayName(context) ?? + item.displayName, + isSelected: false, + ), + ), + ) + .toList(), + onChanged: controller.setSelectedLanguage, + buttonStyleData: ButtonStyleData( + // This is necessary for the ink response to match our customButton radius. + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(40), ), - ], - ) - : const CircularProgressIndicator.adaptive(), + ), + dropdownStyleData: const DropdownStyleData( + offset: Offset(-160, 0), + width: 250, + ), + ), + ), + ], + ), ], ), - ), - ); - }, - ), + controller.initialized + ? Table( + columnWidths: const {0: FlexColumnWidth(2.5)}, + children: [ + TableRow( + decoration: BoxDecoration( + border: Border( + bottom: BorderSide(color: theme.dividerColor), + ), + ), + children: [ + _TableHeaderCell( + text: L10n.of(context).viewingAnalytics( + controller.completedDownloads, + controller.downloads.length, + ), + icon: Icons.group_outlined, + mini: mini, + ), + _TableHeaderCell( + text: L10n.of(context).level, + icon: Icons.star, + mini: mini, + ), + _TableHeaderCell( + text: L10n.of(context).vocab, + icon: Symbols.dictionary, + mini: mini, + ), + _TableHeaderCell( + text: L10n.of(context).grammar, + icon: Symbols.toys_and_games, + mini: mini, + ), + _TableHeaderCell( + text: L10n.of(context).activities, + icon: Icons.radar, + mini: mini, + ), + ], + ), + ...controller.sortedDownloads.mapIndexed( + (index, entry) { + final download = entry.value; + return TableRow( + children: [ + TableCell( + child: Opacity( + opacity: download.requestStatus.opacity, + child: Padding( + padding: EdgeInsets.symmetric( + vertical: !mini ? 12.0 : 4.0, + ), + child: Row( + spacing: !mini ? 16.0 : 8.0, + children: [ + Avatar( + size: !mini ? 64.0 : 40.0, + mxContent: entry.key.avatarUrl, + name: entry.key.calcDisplayname(), + userId: entry.key.id, + presenceUserId: entry.key.id, + ), + Flexible( + child: Column( + spacing: 4.0, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + SizedBox( + height: + index == 0 ? 8.0 : 0.0, + ), + Text( + entry.key.id, + maxLines: 1, + overflow: + TextOverflow.ellipsis, + style: TextStyle( + fontSize: + !mini ? 16.0 : 12.0, + fontWeight: FontWeight.w500, + ), + ), + _RequestButton( + status: + download.requestStatus, + onPressed: () => controller + .requestAnalytics( + entry.key, + ), + mini: mini, + ), + const SizedBox(height: 8.0), + ], + ), + ), + ], + ), + ), + ), + ), + _TableContentCell( + text: download.summary?.level?.toString(), + downloadStatus: download.downloadStatus, + requestStatus: download.requestStatus, + mini: mini, + ), + _TableContentCell( + text: download.summary?.numLemmas.toString(), + downloadStatus: download.downloadStatus, + requestStatus: download.requestStatus, + mini: mini, + ), + _TableContentCell( + text: download.summary?.numMorphConstructs + .toString(), + downloadStatus: download.downloadStatus, + requestStatus: download.requestStatus, + mini: mini, + ), + _TableContentCell( + text: download.summary?.numCompletedActivities + .toString(), + downloadStatus: download.downloadStatus, + requestStatus: download.requestStatus, + mini: mini, + ), + ], + ); + }, + ), + ], + ) + : const CircularProgressIndicator.adaptive(), + ], + ), + ); + }, ); } } @@ -506,34 +488,22 @@ class _RequestButton extends StatelessWidget { child: Opacity( opacity: status.enabled ? 0.9 : 0.3, child: Container( - padding: (status != RequestStatus.unavailable) - ? const EdgeInsets.symmetric( - horizontal: 8.0, - vertical: 4.0, - ) - : null, - decoration: status != RequestStatus.unavailable - ? BoxDecoration( - borderRadius: BorderRadius.circular(40), - color: status.backgroundColor(context), - ) - : null, - child: Wrap( + padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(40), + color: status.backgroundColor(context), + ), + child: Row( spacing: 8.0, + mainAxisSize: MainAxisSize.min, children: [ - if (status.icon != null) - Icon( - status.icon, - size: !mini ? 12.0 : 8.0, - ), + Icon( + status.icon, + size: !mini ? 12.0 : 8.0, + ), Text( status.label(context), - style: TextStyle( - fontSize: !mini ? 12.0 : 8.0, - fontStyle: status == RequestStatus.unavailable - ? FontStyle.italic - : FontStyle.normal, - ), + style: TextStyle(fontSize: !mini ? 12.0 : 8.0), ), ], ), diff --git a/lib/pangea/spaces/controllers/space_controller.dart b/lib/pangea/spaces/controllers/space_controller.dart index 2eae5fb3e..1700590c5 100644 --- a/lib/pangea/spaces/controllers/space_controller.dart +++ b/lib/pangea/spaces/controllers/space_controller.dart @@ -87,7 +87,7 @@ class ClassController extends BaseController { Room? room = client.getRoomByAlias(alias) ?? client.getRoomById(alias); if (room != null) { room.isSpace - ? context.go("/rooms?spaceId=${room.id}") + ? context.go("/rooms/spaces/${room.id}/details") : context.go("/rooms/${room.id}"); return; } @@ -104,7 +104,7 @@ class ClassController extends BaseController { } room.isSpace - ? context.go("/rooms?spaceId=${room.id}") + ? context.go("/rooms/spaces/${room.id}/details") : context.go("/rooms/${room.id}"); } @@ -154,7 +154,7 @@ class ClassController extends BaseController { if (!(room?.isSpace ?? true)) { context.go("/rooms/${alreadyJoined.first}"); } else { - context.go("/rooms?spaceId=${alreadyJoined.first}"); + context.go("/rooms/spaces/${alreadyJoined.first}/details"); } return null; } @@ -212,7 +212,7 @@ class ClassController extends BaseController { } if (room.isSpace) { - context.go("/rooms?spaceId=${room.id}"); + context.go("/rooms/spaces/${room.id}/details"); } else { context.go("/rooms/${room.id}"); } diff --git a/lib/pangea/spaces/utils/client_spaces_extension.dart b/lib/pangea/spaces/utils/client_spaces_extension.dart index fb0a2f5db..746183d8f 100644 --- a/lib/pangea/spaces/utils/client_spaces_extension.dart +++ b/lib/pangea/spaces/utils/client_spaces_extension.dart @@ -19,6 +19,8 @@ extension SpacesClientExtension on Client { JoinRules joinRules = JoinRules.public, Uint8List? avatar, Uri? avatarUrl, + List? initialState, + int spaceChild = 50, }) async { final roomId = await createRoom( creationContent: {'type': RoomCreationTypes.mSpace}, @@ -26,7 +28,10 @@ extension SpacesClientExtension on Client { name: name.trim(), powerLevelContentOverride: {'events_default': 100}, initialState: [ - RoomDefaults.defaultSpacePowerLevels(userID!), + RoomDefaults.defaultSpacePowerLevels( + userID!, + spaceChild: spaceChild, + ), await pangeaJoinRules( joinRules.toString().replaceAll('JoinRules.', ''), ), @@ -35,6 +40,7 @@ extension SpacesClientExtension on Client { type: EventTypes.RoomAvatar, content: {'url': avatarUrl.toString()}, ), + if (initialState != null) ...initialState, ], ); diff --git a/lib/pangea/spaces/utils/load_participants_util.dart b/lib/pangea/spaces/utils/load_participants_util.dart index c7c48fb46..4a79e7dff 100644 --- a/lib/pangea/spaces/utils/load_participants_util.dart +++ b/lib/pangea/spaces/utils/load_participants_util.dart @@ -69,15 +69,8 @@ class LoadParticipantsUtilState extends State { } } - List filteredParticipants(String filter) { - final searchText = filter.toLowerCase(); - final filtered = participants.where((user) { - final displayName = user.displayName?.toLowerCase() ?? ''; - return displayName.contains(searchText) || - user.id.toLowerCase().contains(searchText); - }).toList(); - - filtered.sort((a, b) { + List sortedParticipants() { + participants.sort((a, b) { if (a.id == BotName.byEnvironment) { return 1; } @@ -99,7 +92,7 @@ class LoadParticipantsUtilState extends State { return (bProfile?.level ?? 0).compareTo(aProfile?.level ?? 0); }); - return filtered; + return participants; } Future _cacheLevels() async { diff --git a/lib/pangea/spaces/widgets/leaderboard_participant_list.dart b/lib/pangea/spaces/widgets/leaderboard_participant_list.dart index 8dec7a6b0..fe7a47e7b 100644 --- a/lib/pangea/spaces/widgets/leaderboard_participant_list.dart +++ b/lib/pangea/spaces/widgets/leaderboard_participant_list.dart @@ -48,7 +48,7 @@ class LeaderboardParticipantListState space: widget.space, builder: (participantsLoader) { final participants = participantsLoader - .filteredParticipants("") + .sortedParticipants() .where((p) => p.membership == Membership.join) .toList(); diff --git a/lib/pangea/spaces/widgets/space_view_appbar.dart b/lib/pangea/spaces/widgets/space_view_appbar.dart deleted file mode 100644 index 1faced723..000000000 --- a/lib/pangea/spaces/widgets/space_view_appbar.dart +++ /dev/null @@ -1,146 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:material_symbols_icons/material_symbols_icons.dart'; -import 'package:matrix/matrix.dart'; - -import 'package:fluffychat/l10n/l10n.dart'; -import 'package:fluffychat/pages/chat_list/space_view.dart'; - -class SpaceViewAppbar extends StatelessWidget { - final Function(SpaceActions) onSpaceAction; - final VoidCallback onBack; - final List? joinedParents; - final Function(String) toParentSpace; - final Room? room; - - const SpaceViewAppbar({ - super.key, - required this.onSpaceAction, - required this.onBack, - required this.toParentSpace, - this.joinedParents, - this.room, - }); - - @override - Widget build(BuildContext context) { - final displayname = - room?.getLocalizedDisplayname() ?? L10n.of(context).nothingFound; - - return GestureDetector( - onTap: () => onSpaceAction(SpaceActions.settings), - child: AppBar( - automaticallyImplyLeading: false, - titleSpacing: joinedParents?.isNotEmpty ?? false ? 0.0 : null, - title: Row( - children: [ - if (joinedParents?.isNotEmpty ?? false) - IconButton( - icon: const Icon(Icons.arrow_back_outlined), - onPressed: () => toParentSpace(joinedParents!.first.id), - ), - Flexible( - child: Column( - spacing: 4.0, - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - displayname, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 16), - ), - if (room != null) - Text( - L10n.of(context).countChatsAndCountParticipants( - room!.spaceChildren.length, - room!.summary.mJoinedMemberCount ?? 1, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 12), - ), - ], - ), - ), - ], - ), - actions: [ - PopupMenuButton( - useRootNavigator: true, - onSelected: onSpaceAction, - itemBuilder: (context) => [ - PopupMenuItem( - value: SpaceActions.settings, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.settings_outlined), - const SizedBox(width: 12), - Text(L10n.of(context).settings), - ], - ), - ), - PopupMenuItem( - value: SpaceActions.invite, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.person_add_outlined), - const SizedBox(width: 12), - Text(L10n.of(context).invite), - ], - ), - ), - PopupMenuItem( - value: SpaceActions.groupChat, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Symbols.chat_add_on), - const SizedBox(width: 12), - Text(L10n.of(context).groupChat), - ], - ), - ), - PopupMenuItem( - value: SpaceActions.subspace, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.add), - const SizedBox(width: 12), - Text(L10n.of(context).subspace), - ], - ), - ), - PopupMenuItem( - value: SpaceActions.leave, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.logout_outlined), - const SizedBox(width: 12), - Text(L10n.of(context).leave), - ], - ), - ), - PopupMenuItem( - value: SpaceActions.delete, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.delete_outlined), - const SizedBox(width: 12), - Text(L10n.of(context).delete), - ], - ), - ), - ], - ), - ], - ), - ); - } -} diff --git a/lib/utils/client_manager.dart b/lib/utils/client_manager.dart index c981aae82..13266ba91 100644 --- a/lib/utils/client_manager.dart +++ b/lib/utils/client_manager.dart @@ -140,6 +140,8 @@ abstract class ClientManager { PangeaEventTypes.activitySummary, PangeaEventTypes.constructSummary, PangeaEventTypes.activityRoomIds, + PangeaEventTypes.coursePlan, + PangeaEventTypes.courseUser, // Pangea# }, logLevel: kReleaseMode ? Level.warning : Level.verbose, diff --git a/lib/widgets/layouts/two_column_layout.dart b/lib/widgets/layouts/two_column_layout.dart index f440cfcfd..688feaef1 100644 --- a/lib/widgets/layouts/two_column_layout.dart +++ b/lib/widgets/layouts/two_column_layout.dart @@ -1,21 +1,30 @@ import 'package:flutter/material.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:go_router/go_router.dart'; + +import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/config/themes.dart'; +import 'package:fluffychat/pages/chat_list/chat_list.dart'; +import 'package:fluffychat/pages/settings/settings.dart'; +import 'package:fluffychat/pangea/analytics_page/analytics_page_constants.dart'; +import 'package:fluffychat/pangea/find_your_people/find_your_people_constants.dart'; +import 'package:fluffychat/widgets/navigation_rail.dart'; class TwoColumnLayout extends StatelessWidget { - final Widget mainView; - final Widget sideView; // #Pangea - final Color? dividerColor; + // final Widget mainView; + final GoRouterState state; // Pangea# + final Widget sideView; const TwoColumnLayout({ super.key, - required this.mainView, - required this.sideView, // #Pangea - this.dividerColor, + // required this.mainView, + required this.state, // Pangea# + required this.sideView, }); @override Widget build(BuildContext context) { @@ -25,19 +34,37 @@ class TwoColumnLayout extends StatelessWidget { child: Scaffold( body: Row( children: [ - Container( - clipBehavior: Clip.antiAlias, - decoration: const BoxDecoration(), - width: FluffyThemes.columnWidth + FluffyThemes.navRailWidth, - child: mainView, - ), - Container( - width: 1.0, - // #Pangea - // color: theme.dividerColor, - color: dividerColor ?? theme.dividerColor, + // #Pangea + if (FluffyThemes.isColumnMode(context) || + !(state.fullPath?.endsWith(":roomid") ?? false)) ...[ + SpacesNavigationRail( + activeSpaceId: state.pathParameters['spaceid'], + path: state.fullPath, + ), + Container( + color: Theme.of(context).dividerColor, + width: 1, + ), + ], + if (FluffyThemes.isColumnMode(context)) ...[ // Pangea# - ), + Container( + clipBehavior: Clip.antiAlias, + decoration: const BoxDecoration(), + // #Pangea + // width: FluffyThemes.columnWidth + FluffyThemes.navRailWidth, + // child: mainView, + width: FluffyThemes.columnWidth, + child: _MainView(state: state), + // Pangea# + ), + Container( + width: 1.0, + color: theme.dividerColor, + ), + // Pangea# + ], + // Pangea# Expanded( child: ClipRRect( child: sideView, @@ -49,3 +76,59 @@ class TwoColumnLayout extends StatelessWidget { ); } } + +// #Pangea +class _MainView extends StatelessWidget { + final GoRouterState state; + + const _MainView({ + required this.state, + }); + + String get _asset { + const defaultAsset = FindYourPeopleConstants.sideBearFileName; + if (state.fullPath == null || state.fullPath!.isEmpty) return defaultAsset; + + if (state.fullPath!.contains('analytics')) { + return AnalyticsPageConstants.dinoBotFileName; + } + + return defaultAsset; + } + + @override + Widget build(BuildContext context) { + final path = state.fullPath; + if (path == null) { + return ChatList( + activeChat: state.pathParameters['roomid'], + activeSpaceId: state.pathParameters['spaceid'], + ); + } + + if (path.contains("settings")) { + return Settings(key: state.pageKey); + } + + if (['communities', 'analytics'].any((p) => path.contains(p))) { + return Center( + child: SizedBox( + width: 250.0, + child: CachedNetworkImage( + imageUrl: "${AppConfig.assetsBaseURL}/$_asset", + errorWidget: (context, url, error) => const SizedBox(), + placeholder: (context, url) => const Center( + child: CircularProgressIndicator.adaptive(), + ), + ), + ), + ); + } + + return ChatList( + activeChat: state.pathParameters['roomid'], + activeSpaceId: state.pathParameters['spaceid'], + ); + } +} +// Pangea# diff --git a/lib/widgets/navigation_rail.dart b/lib/widgets/navigation_rail.dart index 7d8a93f39..e640a6bae 100644 --- a/lib/widgets/navigation_rail.dart +++ b/lib/widgets/navigation_rail.dart @@ -15,18 +15,18 @@ import 'package:fluffychat/widgets/matrix.dart'; class SpacesNavigationRail extends StatelessWidget { final String? activeSpaceId; - final void Function() onGoToChats; - final void Function(String) onGoToSpaceId; // #Pangea - final void Function()? clearActiveSpace; + // final void Function() onGoToChats; + // final void Function(String) onGoToSpaceId; + final String? path; // Pangea# const SpacesNavigationRail({ required this.activeSpaceId, - required this.onGoToChats, - required this.onGoToSpaceId, // #Pangea - this.clearActiveSpace, + // required this.onGoToChats, + // required this.onGoToSpaceId, + required this.path, // Pangea# super.key, }); @@ -41,9 +41,8 @@ class SpacesNavigationRail extends StatelessWidget { .path .startsWith('/rooms/settings'); // #Pangea - final path = GoRouter.of(context).routeInformationProvider.value.uri.path; - final isAnalytics = path.contains('analytics'); - final isCommunities = path.contains('communities'); + final isAnalytics = path?.contains('analytics') ?? false; + final isCommunities = path?.contains('communities') ?? false; final isColumnMode = FluffyThemes.isColumnMode(context); final width = isColumnMode @@ -91,7 +90,6 @@ class SpacesNavigationRail extends StatelessWidget { return NaviRailItem( isSelected: isAnalytics, onTap: () { - clearActiveSpace?.call(); context.go("/rooms/analytics"); }, backgroundColor: Colors.transparent, @@ -127,9 +125,7 @@ class SpacesNavigationRail extends StatelessWidget { !isSettings && !isAnalytics && !isCommunities, - // Pangea# - onTap: onGoToChats, - // #Pangea + // onTap: onGoToChats, // icon: const Padding( // padding: EdgeInsets.all(10.0), // child: Icon(Icons.forum_outlined), @@ -140,6 +136,7 @@ class SpacesNavigationRail extends StatelessWidget { // ), icon: const Icon(Icons.forum_outlined), selectedIcon: const Icon(Icons.forum), + onTap: () => context.go("/rooms"), // Pangea# toolTip: L10n.of(context).chats, unreadBadgeFilter: (room) => true, @@ -158,7 +155,6 @@ class SpacesNavigationRail extends StatelessWidget { // toolTip: L10n.of(context).createNewSpace, isSelected: isCommunities, onTap: () { - clearActiveSpace?.call(); context.go('/rooms/communities'); }, icon: const Icon(Icons.groups), @@ -186,7 +182,9 @@ class SpacesNavigationRail extends StatelessWidget { room, ); } else { - onGoToSpaceId(rootSpaces[i].id); + context.go( + "/rooms/spaces/${rootSpaces[i].id}/details", + ); } }, // Pangea#