diff --git a/.env b/.env index 38131931d..84bdb5b97 100644 --- a/.env +++ b/.env @@ -11,4 +11,5 @@ RC_OFFERING_NAME = 'test' STRIPE_MANAGEMENT_LINK = 'https://billing.stripe.com/p/login/test_9AQaI8d3O9lmaXe5kk' -SUPPORT_SPACE_ID = '!gqSNSkvwTpgumyjLsV:staging.pangea.chat' \ No newline at end of file +SUPPORT_SPACE_ID = '!gqSNSkvwTpgumyjLsV:staging.pangea.chat' +GOOGLE_API_KEY = 'AIzaSyD1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q' \ No newline at end of file diff --git a/lib/config/routes.dart b/lib/config/routes.dart index 37e3b853c..4f5d1e6c2 100644 --- a/lib/config/routes.dart +++ b/lib/config/routes.dart @@ -352,6 +352,25 @@ abstract class AppRoutes { : 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, + ), + ], ), ], ), diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 9b53f1a64..cf5b3bccd 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -4896,7 +4896,6 @@ "newChatActivityDesc": "Make every group chat an adventure with Activity Planner! Set captivating topics and objectives for the group, and bring conversations to life with stunning images. Spark imaginative discussions and keep the fun flowing effortlessly!", "exploreMore": "Explore more", "wordFocusListeningMultipleChoice": "Which audio matches the word?", - "createActivity": "Create activity", "startChat": "Start a chat", "translationProblem": "Translation problem", "perfectTranslation": "Perfect translation!", @@ -5080,6 +5079,7 @@ "reset": "Reset", "errorGenerateActivityMessage": "Failed to generate activity", "errorRegenerateActivityMessage": "Failed to regenerate activity", + "errorLaunchActivityMessage": "Failed to launch activity", "errorFetchingActivitiesMessage": "Failed to fetch activities", "errorFetchingDefinition": "Failed to fetch definition", "errorProcessAnalytics": "Failed to process analytics", @@ -5092,5 +5092,46 @@ "errorFetchingTranslation": "Failed to fetch translation", "errorFetchingActivity": "Failed to fetch activity", "check": "Check", - "unableToFindRoom": "No chat or space found with that code. Please try again." + "unableToFindRoom": "No chat or space found with that code. Please try again.", + "createActivityPlan": "Create a new activity plan", + "saveAndLaunch": "Save and Launch", + "launchToSpace": "Launch to Space", + "numberOfActivities": "Number of Activity Sessions", + "minimumActivityParticipants": "Each Activity needs a minimum {count} of participant(s).", + "@minimumActivityParticipants": { + "type": "String", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "unjoinedActivityMessage": "Do you want to participate? Choose an open role!\nOr hang out and watch the show!", + "fullActivityMessage": "Feel free to watch the show! While there aren't any open roles to participate, you can view the chat!", + "confirmRole": "Confirm role", + "openRoleLabel": "OPEN", + "joinedTheActivity": "👋 {username} joined as {role}", + "@joinedTheActivity": { + "type": "String", + "placeholders": { + "username": { + "type": "String" + }, + "role": { + "type": "String" + } + } + }, + "finishedTheActivity": "🎯 {username} wrapped up this activity", + "@finishedTheActivity": { + "type": "String", + "placeholders": { + "username": { + "type": "String" + } + } + }, + "endActivityTitle": "Wrapped up my part", + "endActivityDesc": "Did you complete the objectives?\nThis is your confirmation that you're stepping back from texting. But don’t worry, the fun continues in the chat! Feel free to hang out and enjoy the show until everyone clicks 'Done'.", + "archiveToAnalytics": "Archive to Analytics" } diff --git a/lib/l10n/intl_es.arb b/lib/l10n/intl_es.arb index e03c9d6c6..91bdfcce3 100644 --- a/lib/l10n/intl_es.arb +++ b/lib/l10n/intl_es.arb @@ -5532,7 +5532,6 @@ "save": "Guardar", "selectActivity": "Seleccionar actividad", "wordFocusListeningMultipleChoice": "¿Qué audio coincide con la palabra?", - "createActivity": "Crear actividad", "startChat": "Iniciar un chat", "translationProblem": "Problema de traducción", "perfectTranslation": "¡Traducción perfecta!", diff --git a/lib/l10n/intl_vi.arb b/lib/l10n/intl_vi.arb index 94929206c..b72c8795e 100644 --- a/lib/l10n/intl_vi.arb +++ b/lib/l10n/intl_vi.arb @@ -3832,7 +3832,6 @@ "save": "Lưu", "selectActivity": "Chọn hoạt động", "wordFocusListeningMultipleChoice": "Âm thanh nào phù hợp với từ?", - "createActivity": "Tạo hoạt động", "startChat": "Bắt đầu trò chuyện", "translationProblem": "Vấn đề dịch thuật", "perfectTranslation": "Dịch thuật hoàn hảo!", diff --git a/lib/pages/archive/archive.dart b/lib/pages/archive/archive.dart index 5780c6fe7..c5bd5e6b0 100644 --- a/lib/pages/archive/archive.dart +++ b/lib/pages/archive/archive.dart @@ -24,7 +24,7 @@ class ArchiveController extends State { // #Pangea //return archive = await Matrix.of(context).client.loadArchive(); return archive = (await Matrix.of(context).client.loadArchive()) - .where((e) => (!e.isSpace && !e.isAnalyticsRoom)) + .where((e) => (!e.isSpace && !e.isHiddenRoom)) .toList(); // Pangea# } diff --git a/lib/pages/chat/chat.dart b/lib/pages/chat/chat.dart index 85be85d36..ea4efd4d9 100644 --- a/lib/pages/chat/chat.dart +++ b/lib/pages/chat/chat.dart @@ -81,11 +81,18 @@ class ChatPage extends StatelessWidget { final List? shareItems; final String? eventId; + // #Pangea + final Widget? backButton; + // Pangea# + const ChatPage({ super.key, required this.roomId, this.eventId, this.shareItems, + // #Pangea + this.backButton, + // Pangea# }); @override @@ -122,6 +129,9 @@ class ChatPage extends StatelessWidget { room: room, shareItems: shareItems, eventId: eventId, + // #Pangea + backButton: backButton, + // Pangea# ); } } @@ -131,11 +141,18 @@ class ChatPageWithRoom extends StatefulWidget { final List? shareItems; final String? eventId; + // #Pangea + final Widget? backButton; + // Pangea# + const ChatPageWithRoom({ super.key, required this.room, this.shareItems, this.eventId, + // #Pangea + this.backButton, + // Pangea# }); @override diff --git a/lib/pages/chat/chat_view.dart b/lib/pages/chat/chat_view.dart index e4b65f290..1113b2b27 100644 --- a/lib/pages/chat/chat_view.dart +++ b/lib/pages/chat/chat_view.dart @@ -13,6 +13,9 @@ import 'package:fluffychat/pages/chat/chat_app_bar_list_tile.dart'; import 'package:fluffychat/pages/chat/chat_app_bar_title.dart'; import 'package:fluffychat/pages/chat/chat_event_list.dart'; import 'package:fluffychat/pages/chat/pinned_events.dart'; +import 'package:fluffychat/pangea/activity_planner/activity_pinned_message.dart'; +import 'package:fluffychat/pangea/activity_planner/activity_room_extension.dart'; +import 'package:fluffychat/pangea/activity_planner/activity_status_message.dart'; import 'package:fluffychat/pangea/chat/widgets/chat_input_bar.dart'; import 'package:fluffychat/pangea/chat/widgets/chat_input_bar_header.dart'; import 'package:fluffychat/pangea/chat/widgets/chat_view_background.dart'; @@ -24,7 +27,9 @@ import 'package:fluffychat/widgets/mxc_image.dart'; import 'package:fluffychat/widgets/unread_rooms_badge.dart'; import '../../utils/stream_extension.dart'; -enum _EventContextAction { info, report } +// #Pangea +// enum _EventContextAction { info, report } +// Pangea# class ChatView extends StatelessWidget { final ChatController controller; @@ -210,31 +215,34 @@ class ChatView extends StatelessWidget { // : theme.colorScheme.tertiaryContainer, // Pangea# automaticallyImplyLeading: false, - leading: + leading: controller.selectMode + ? IconButton( + icon: const Icon(Icons.close), + onPressed: controller.clearSelectedEvents, + tooltip: L10n.of(context).close, + color: theme.colorScheme.onTertiaryContainer, + ) // #Pangea - // controller.selectMode - // ? IconButton( - // icon: const Icon(Icons.close), - // onPressed: controller.clearSelectedEvents, - // tooltip: L10n.of(context).close, - // color: theme.colorScheme.onTertiaryContainer, - // ) - // : - // Pangea# - FluffyThemes.isColumnMode(context) - ? null - : StreamBuilder( - stream: - Matrix.of(context).client.onSync.stream.where( + : controller.widget.backButton != null + ? controller.widget.backButton! + // Pangea# + : FluffyThemes.isColumnMode(context) + ? null + : StreamBuilder( + stream: Matrix.of(context) + .client + .onSync + .stream + .where( (syncUpdate) => syncUpdate.hasRoomUpdate, ), - builder: (context, _) => UnreadRoomsBadge( - filter: (r) => r.id != controller.roomId, - badgePosition: - BadgePosition.topEnd(end: 8, top: 4), - child: const Center(child: BackButton()), - ), - ), + builder: (context, _) => UnreadRoomsBadge( + filter: (r) => r.id != controller.roomId, + badgePosition: + BadgePosition.topEnd(end: 8, top: 4), + child: const Center(child: BackButton()), + ), + ), titleSpacing: FluffyThemes.isColumnMode(context) ? 24 : 0, title: ChatAppBarTitle(controller), actions: _appBarActions(context), @@ -413,13 +421,18 @@ class ChatView extends StatelessWidget { ), // #Pangea // Keep messages above minimum input bar height - if (!controller.room.isAbandonedDMRoom) + if (!controller.room.isAbandonedDMRoom && + controller.room.canSendDefaultMessages && + controller.room.membership == Membership.join && + controller.room.hasJoinedActivity && + !controller.room.hasFinishedActivity) AnimatedSize( duration: const Duration(milliseconds: 200), child: SizedBox( height: controller.inputBarHeight, ), ), + ActivityStatusMessage(room: controller.room), // Pangea# ], ), @@ -427,7 +440,9 @@ class ChatView extends StatelessWidget { ChatViewBackground(controller.choreographer), if (!controller.room.isAbandonedDMRoom && controller.room.canSendDefaultMessages && - controller.room.membership == Membership.join) + controller.room.membership == Membership.join && + controller.room.hasJoinedActivity && + !controller.room.hasFinishedActivity) Positioned( left: 0, right: 0, @@ -447,6 +462,7 @@ class ChatView extends StatelessWidget { ], ), ), + ActivityPinnedMessage(controller), // Pangea# ], ), diff --git a/lib/pages/chat/events/message.dart b/lib/pages/chat/events/message.dart index 204778a2d..6b6761462 100644 --- a/lib/pages/chat/events/message.dart +++ b/lib/pages/chat/events/message.dart @@ -10,7 +10,10 @@ import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/pages/chat/chat.dart'; import 'package:fluffychat/pages/chat/events/pangea_message_reactions.dart'; import 'package:fluffychat/pages/chat/events/room_creation_state_event.dart'; +import 'package:fluffychat/pangea/chat/widgets/activity_role_state_message.dart'; +import 'package:fluffychat/pangea/chat/widgets/activity_state_event.dart'; import 'package:fluffychat/pangea/common/widgets/pressable_button.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/utils/date_time_extension.dart'; import 'package:fluffychat/utils/file_description.dart'; @@ -125,6 +128,17 @@ class Message extends StatelessWidget { if (event.type == EventTypes.RoomCreate) { return RoomCreationStateEvent(event: event); } + + // #Pangea + if (event.type == PangeaEventTypes.activityPlan) { + return ActivityStateEvent(event: event); + } + + if (event.type == PangeaEventTypes.activityRole) { + return ActivityRoleStateMessage(event); + } + // Pangea# + return StateMessage(event); } diff --git a/lib/pages/chat_details/chat_details.dart b/lib/pages/chat_details/chat_details.dart index 26aece57d..853da6a85 100644 --- a/lib/pages/chat_details/chat_details.dart +++ b/lib/pages/chat_details/chat_details.dart @@ -10,14 +10,12 @@ 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/utils/download_chat.dart'; import 'package:fluffychat/pangea/chat_settings/utils/download_file.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.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'; @@ -315,50 +313,9 @@ class ChatDetailsController extends State { } Future addSubspace() async { - final names = await showTextInputDialog( - context: context, - title: L10n.of(context).createNewSpace, - hintText: L10n.of(context).spaceName, - 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; - await showFutureLoadingDialog( - context: context, - future: () async { - final activeSpace = client.getRoomById(roomId!)!; - await activeSpace.postLoad(); - - final resp = await client.createRoom( - name: names, - visibility: RoomDefaults.spaceChildVisibility, - creationContent: {'type': 'm.space'}, - initialState: [ - RoomDefaults.defaultSpacePowerLevels(client.userID!), - await client.pangeaJoinRules( - 'knock_restricted', - allow: [ - { - "type": "m.room_membership", - "room_id": roomId!, - } - ], - ), - ], - ); - await activeSpace.addToSpace(resp); - }, - ); + final activeSpace = Matrix.of(context).client.getRoomById(roomId!); + if (activeSpace == null || !activeSpace.isSpace) return; + await activeSpace.addSubspace(context); } // Pangea# } diff --git a/lib/pages/chat_list/chat_list.dart b/lib/pages/chat_list/chat_list.dart index e73a48467..9ce09ee5c 100644 --- a/lib/pages/chat_list/chat_list.dart +++ b/lib/pages/chat_list/chat_list.dart @@ -265,24 +265,24 @@ class ChatListController extends State case ActiveFilter.allChats: // #Pangea // return (room) => true; - return (room) => !room.isAnalyticsRoom && !room.isSpace; + return (room) => !room.isHiddenRoom && !room.isSpace; // Pangea# case ActiveFilter.messages: // #Pangea // return (room) => !room.isSpace && room.isDirectChat; return (room) => - !room.isSpace && room.isDirectChat && !room.isAnalyticsRoom; + !room.isSpace && room.isDirectChat && !room.isHiddenRoom; // Pangea# case ActiveFilter.groups: // #Pangea // return (room) => !room.isSpace && !room.isDirectChat; return (room) => - !room.isSpace && !room.isDirectChat && !room.isAnalyticsRoom; + !room.isSpace && !room.isDirectChat && !room.isHiddenRoom; // Pangea# case ActiveFilter.unread: // #Pangea // return (room) => room.isUnreadOrInvited; - return (room) => room.isUnreadOrInvited && !room.isAnalyticsRoom; + return (room) => room.isUnreadOrInvited && !room.isHiddenRoom; // Pangea# case ActiveFilter.spaces: return (room) => room.isSpace; @@ -650,7 +650,7 @@ class ChatListController extends State final room = client.getRoomById(roomID); if (room == null || room.isSpace || - room.isAnalyticsRoom || + room.isHiddenRoom || room.capacity == null || (room.summary.mJoinedMemberCount ?? 1) <= room.capacity!) { continue; @@ -1113,12 +1113,10 @@ class ChatListController extends State if (confirmed != OkCancelResult.ok) return; if (!mounted) return; - final resp = await showFutureLoadingDialog( + await showFutureLoadingDialog( context: context, future: room.delete, ); - if (resp.isError) return; - if (mounted) context.go("/rooms?spaceId=clear"); } return; // Pangea# diff --git a/lib/pages/chat_list/space_view.dart b/lib/pages/chat_list/space_view.dart index 929431f1b..1c7975dac 100644 --- a/lib/pages/chat_list/space_view.dart +++ b/lib/pages/chat_list/space_view.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:collection/collection.dart'; @@ -22,6 +21,7 @@ import 'package:fluffychat/pangea/public_spaces/public_room_bottom_sheet.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/show_ok_cancel_alert_dialog.dart'; @@ -391,6 +391,13 @@ class _SpaceViewState extends State { 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# } } @@ -552,8 +559,10 @@ class _SpaceViewState extends State { final theme = Theme.of(context); final room = Matrix.of(context).client.getRoomById(widget.spaceId); - final displayname = - room?.getLocalizedDisplayname() ?? L10n.of(context).nothingFound; + // #Pangea + // final displayname = + // room?.getLocalizedDisplayname() ?? L10n.of(context).nothingFound; + // Pangea# // #Pangea final joinedParents = room?.spaceParents @@ -567,167 +576,92 @@ class _SpaceViewState extends State { // Pangea# return Scaffold( - // #Pangea - // appBar: AppBar( appBar: PreferredSize( - preferredSize: Size.fromHeight( - kIsWeb ? 72.0 : (kToolbarHeight + MediaQuery.of(context).padding.top), - ), - child: GestureDetector( - onTap: () { - _onSpaceAction(SpaceActions.settings); - }, - child: AppBar( - // leading: FluffyThemes.isColumnMode(context) - // ? null - // : Center( - // child: CloseButton( - // onPressed: widget.onBack, - // ), - // ), - leading: joinedParents?.isEmpty ?? true - ? FluffyThemes.isColumnMode(context) - ? null - : Center( - child: CloseButton( - onPressed: widget.onBack, - ), - ) - : Center( - child: joinedParents!.length == 1 - ? IconButton( - icon: const Icon(Icons.arrow_back_outlined), - onPressed: () => - widget.toParentSpace(joinedParents.first.id), - ) - : PopupMenuButton( - tooltip: null, - useRootNavigator: true, - icon: const Icon(Icons.arrow_back_outlined), - itemBuilder: (context) { - return [ - ...joinedParents.mapIndexed((i, room) { - return PopupMenuItem( - value: i, - child: Text(room.getLocalizedDisplayname()), - ); - }), - ]; - }, - onSelected: (i) { - widget.toParentSpace(joinedParents[i].id); - }, - ), - ), - // Pangea# - automaticallyImplyLeading: false, - titleSpacing: FluffyThemes.isColumnMode(context) ? null : 0, - title: ListTile( - contentPadding: EdgeInsets.zero, - leading: Avatar( - mxContent: room?.avatar, - name: displayname, - // #Pangea - userId: room?.directChatMatrixID, - // Pangea# - 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( - // #Pangea - // room.spaceChildren.length, - room.spaceChildCount, - // Pangea# - 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: [ - // #Pangea - // const Icon(Icons.delete_outlined), - const Icon(Icons.logout_outlined), - // Pangea# - const SizedBox(width: 12), - Text(L10n.of(context).leave), - ], - ), - ), - // #Pangea - if (Matrix.of(context) - .client - .getRoomById(widget.spaceId) - ?.isRoomAdmin ?? - false) - PopupMenuItem( - value: SpaceActions.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, - ), - ), - ], - ), - ), - // Pangea# - ], - ), - ], - ), + 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), + // ], + // ), + // ), + // ], + // ), + // ], + // ), floatingActionButton: room?.canChangeStateEvent( EventTypes.SpaceChild, ) == @@ -736,11 +670,12 @@ class _SpaceViewState extends State { // #Pangea // onPressed: _addChatOrSubspace, // label: Text(L10n.of(context).group), + // icon: const Icon(Icons.group_add_outlined), onPressed: () => - context.go("/rooms/newgroup?space=${widget.spaceId}"), - label: Text(L10n.of(context).groupChat), + context.go("/rooms/${widget.spaceId}/details/planner"), + label: Text(L10n.of(context).activities), + icon: const Icon(Icons.event_note_outlined), // Pangea# - icon: const Icon(Icons.group_add_outlined), ) : null, body: room == null @@ -763,7 +698,7 @@ class _SpaceViewState extends State { final joinedRooms = room.client.rooms .where((room) => childrenIds.remove(room.id)) // #Pangea - .where((room) => !room.isAnalyticsRoom) + .where((room) => !room.isHiddenRoom) // Pangea# .toList(); @@ -1025,5 +960,7 @@ enum SpaceActions { leave, // #Pangea delete, + groupChat, + subspace, // Pangea# } diff --git a/lib/pages/chat_permissions_settings/chat_permissions_settings_view.dart b/lib/pages/chat_permissions_settings/chat_permissions_settings_view.dart index fa41ea16c..0e8f5da68 100644 --- a/lib/pages/chat_permissions_settings/chat_permissions_settings_view.dart +++ b/lib/pages/chat_permissions_settings/chat_permissions_settings_view.dart @@ -5,6 +5,7 @@ import 'package:matrix/matrix.dart'; import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/pages/chat_permissions_settings/chat_permissions_settings.dart'; import 'package:fluffychat/pages/chat_permissions_settings/permission_list_tile.dart'; +import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart'; import 'package:fluffychat/widgets/layouts/max_width_body.dart'; import 'package:fluffychat/widgets/matrix.dart'; @@ -49,6 +50,8 @@ class ChatPermissionsSettingsView extends StatelessWidget { controller.defaultPowerLevels, ); + final excludedEvents = [PangeaEventTypes.activityRole]; + Map missingPowerLevels = Map.from( defaults, )..removeWhere((k, v) => v is! int || powerLevels.containsKey(k)); @@ -72,6 +75,9 @@ class ChatPermissionsSettingsView extends StatelessWidget { powerLevels.addAll(missingPowerLevels); eventsPowerLevels.addAll(missingEventsPowerLevels); + eventsPowerLevels.removeWhere( + (key, value) => excludedEvents.contains(key), + ); // Pangea# return Column( children: [ diff --git a/lib/pages/new_group/new_group.dart b/lib/pages/new_group/new_group.dart index 0c59d7ba4..60975b758 100644 --- a/lib/pages/new_group/new_group.dart +++ b/lib/pages/new_group/new_group.dart @@ -9,6 +9,7 @@ import 'package:matrix/matrix.dart'; import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/pages/new_group/new_group_view.dart'; import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart'; +import 'package:fluffychat/pangea/activity_planner/activity_room_extension.dart'; import 'package:fluffychat/pangea/chat/constants/default_power_level.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/common/utils/firebase_analytics.dart'; diff --git a/lib/pages/new_group/new_group_view.dart b/lib/pages/new_group/new_group_view.dart index b5f1ba32a..2f92259e3 100644 --- a/lib/pages/new_group/new_group_view.dart +++ b/lib/pages/new_group/new_group_view.dart @@ -3,7 +3,6 @@ import 'package:flutter/material.dart'; import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/pages/new_group/new_group.dart'; -import 'package:fluffychat/pangea/activity_suggestions/activity_suggestion_carousel.dart'; import 'package:fluffychat/widgets/avatar.dart'; import 'package:fluffychat/widgets/layouts/max_width_body.dart'; @@ -175,17 +174,6 @@ class NewGroupView extends StatelessWidget { // onChanged: null, // ), // ), - if (controller.createGroupType == CreateGroupType.group) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 24.0), - child: ActivitySuggestionCarousel( - onActivitySelected: controller.setSelectedActivity, - enabled: controller.nameController.text.isNotEmpty && - !controller.loading, - selectedActivity: controller.selectedActivity, - selectedActivityImage: controller.selectedActivityImage, - ), - ), // Pangea# AnimatedSize( duration: FluffyThemes.animationDuration, diff --git a/lib/pangea/activity_generator/activity_generator.dart b/lib/pangea/activity_generator/activity_generator.dart index 00820afb1..abb964e26 100644 --- a/lib/pangea/activity_generator/activity_generator.dart +++ b/lib/pangea/activity_generator/activity_generator.dart @@ -21,9 +21,9 @@ import 'package:fluffychat/pangea/learning_settings/enums/language_level_type_en import 'package:fluffychat/widgets/matrix.dart'; class ActivityGenerator extends StatefulWidget { - final String? roomID; + final String roomID; const ActivityGenerator({ - this.roomID, + required this.roomID, super.key, }); @@ -53,6 +53,7 @@ class ActivityGeneratorState extends State { @override void initState() { super.initState(); + selectedLanguageOfInstructions = MatrixState.pangeaController.languageController.userL1?.langCode; selectedTargetLanguage = @@ -96,9 +97,7 @@ class ActivityGeneratorState extends State { Future> get objectiveItems => LearningObjectiveListRepo.get(req); - Room? get room => widget.roomID != null - ? Matrix.of(context).client.getRoomById(widget.roomID!) - : null; + Room? get room => Matrix.of(context).client.getRoomById(widget.roomID); String? validateNotNull(String? value) { if (value == null || value.isEmpty) { diff --git a/lib/pangea/activity_generator/activity_generator_view.dart b/lib/pangea/activity_generator/activity_generator_view.dart index 612695b76..7b69f0964 100644 --- a/lib/pangea/activity_generator/activity_generator_view.dart +++ b/lib/pangea/activity_generator/activity_generator_view.dart @@ -36,7 +36,7 @@ class ActivityGeneratorView extends StatelessWidget { padding: EdgeInsets.all(32.0), child: Center(child: CircularProgressIndicator()), ); - } else if (controller.error != null) { + } else if (controller.error != null || controller.room == null) { body = Center( child: Column( spacing: 16.0, @@ -61,7 +61,7 @@ class ActivityGeneratorView extends StatelessWidget { return ActivityPlannerBuilder( initialActivity: controller.activities![index], initialFilename: controller.filename, - room: controller.room, + room: controller.room!, builder: (c) { return ActivityPlanCard( regenerate: () => controller.generate(force: true), diff --git a/lib/pangea/activity_planner/activity_finished_status_message.dart b/lib/pangea/activity_planner/activity_finished_status_message.dart new file mode 100644 index 000000000..4c99c0d95 --- /dev/null +++ b/lib/pangea/activity_planner/activity_finished_status_message.dart @@ -0,0 +1,259 @@ +import 'package:flutter/material.dart'; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:collection/collection.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/pangea/activity_planner/activity_participant_indicator.dart'; +import 'package:fluffychat/pangea/activity_planner/activity_results_carousel.dart'; +import 'package:fluffychat/pangea/activity_planner/activity_role_model.dart'; +import 'package:fluffychat/pangea/activity_planner/activity_room_extension.dart'; +import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; +import 'package:fluffychat/widgets/future_loading_dialog.dart'; +import 'package:fluffychat/widgets/matrix.dart'; +import 'package:fluffychat/widgets/mxc_image.dart'; + +class ActivityFinishedStatusMessage extends StatefulWidget { + final Room room; + + const ActivityFinishedStatusMessage({ + super.key, + required this.room, + }); + + @override + ActivityFinishedStatusMessageState createState() => + ActivityFinishedStatusMessageState(); +} + +class ActivityFinishedStatusMessageState + extends State { + ActivityRoleModel? _highlightedRole; + bool _expanded = true; + + @override + void initState() { + super.initState(); + _setDefaultHighlightedRole(); + } + + @override + void didUpdateWidget(ActivityFinishedStatusMessage oldWidget) { + super.didUpdateWidget(oldWidget); + _setDefaultHighlightedRole(); + } + + void _setExpanded(bool expanded) { + if (mounted) setState(() => _expanded = expanded); + } + + int get _hightlightedRoleIndex { + if (_highlightedRole == null) { + return -1; // No highlighted role + } + return widget.room.activityRoles.indexOf(_highlightedRole!); + } + + void _setDefaultHighlightedRole() { + if (_hightlightedRoleIndex >= 0) return; + + final roles = widget.room.activityRoles; + _highlightedRole = roles.firstWhereOrNull( + (r) => r.userId == widget.room.client.userID, + ); + + if (_highlightedRole == null && roles.isNotEmpty) { + _highlightedRole = roles.first; + } + + if (mounted) setState(() {}); + } + + void _highlightRole(ActivityRoleModel role) { + if (mounted) setState(() => _highlightedRole = role); + } + + bool get _canMoveLeft => + _hightlightedRoleIndex > 0 && _highlightedRole != null; + + bool get _canMoveRight => + _hightlightedRoleIndex < widget.room.activityRoles.length - 1 && + _highlightedRole != null; + + void _moveLeft() { + if (_hightlightedRoleIndex > 0) { + _highlightRole(widget.room.activityRoles[_hightlightedRoleIndex - 1]); + } + } + + void _moveRight() { + if (_hightlightedRoleIndex < widget.room.activityRoles.length - 1) { + _highlightRole(widget.room.activityRoles[_hightlightedRoleIndex + 1]); + } + } + + Future _archiveToAnalytics() async { + final role = widget.room.activityRole(widget.room.client.userID!); + if (role == null) { + throw Exception( + "Cannot archive activity without a role for user ${widget.room.client.userID!}", + ); + } + + role.archivedAt = DateTime.now(); + await widget.room.archiveActivity(); + await MatrixState.pangeaController.putAnalytics + .sendActivityAnalytics(widget.room.id); + } + + @override + Widget build(BuildContext context) { + final summary = widget.room.activitySummary; + final imageURL = widget.room.activityPlan!.imageURL; + + final theme = Theme.of(context); + final isColumnMode = MediaQuery.of(context).size.width < 600; + + final user = widget.room.getParticipants().firstWhereOrNull( + (u) => u.id == _highlightedRole?.userId, + ); + final userSummary = + widget.room.activitySummary?.participants.firstWhereOrNull( + (p) => p.participantId == _highlightedRole!.userId, + ); + + return AnimatedSize( + duration: FluffyThemes.animationDuration, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: _expanded + ? [ + if (summary != null) ...[ + IconButton( + icon: Icon( + Icons.expand_more, + color: theme.colorScheme.onSurfaceVariant, + ), + onPressed: () => _setExpanded(!_expanded), + ), + const SizedBox(height: 8.0), + if (imageURL != null) + ClipRRect( + borderRadius: BorderRadius.circular(100), + child: imageURL.startsWith("mxc") + ? MxcImage( + uri: Uri.parse(imageURL), + width: 100.0, + height: 100.0, + cacheKey: widget.room.activityPlan!.bookmarkId, + fit: BoxFit.cover, + ) + : CachedNetworkImage( + imageUrl: imageURL, + fit: BoxFit.cover, + width: 100.0, + height: 100.0, + placeholder: ( + context, + url, + ) => + const Center( + child: CircularProgressIndicator(), + ), + errorWidget: ( + context, + url, + error, + ) => + const SizedBox(), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0), + child: Text( + summary.summary, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: isColumnMode ? 16.0 : 12.0, + ), + ), + ), + if (_highlightedRole != null && + user != null && + userSummary != null) + ActivityResultsCarousel( + selectedRole: _highlightedRole!, + moveLeft: _canMoveLeft ? _moveLeft : null, + moveRight: _canMoveRight ? _moveRight : null, + user: user, + summary: userSummary, + ), + const SizedBox(height: 8.0), + Wrap( + spacing: 12.0, + runSpacing: 12.0, + children: widget.room.activityRoles + .map( + (role) => Opacity( + opacity: _highlightedRole == role ? 1.0 : 0.5, + child: ActivityParticipantIndicator( + onTap: _highlightedRole == role + ? null + : () => _highlightRole(role), + role: role, + displayname: role.userId.localpart, + ), + ), + ) + .toList(), + ), + const SizedBox(height: 20.0), + ], + if (!widget.room.isHiddenActivityRoom) + ElevatedButton( + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 12.0, + vertical: 8.0, + ), + foregroundColor: theme.colorScheme.onPrimaryContainer, + backgroundColor: theme.colorScheme.primaryContainer, + ), + onPressed: () async { + final resp = await showFutureLoadingDialog( + context: context, + future: _archiveToAnalytics, + ); + + if (!resp.isError) { + context.go( + "/rooms/analytics?mode=activities", + ); + } + }, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(L10n.of(context).archiveToAnalytics), + ], + ), + ), + ] + : [ + if (summary != null) + IconButton( + icon: Icon( + Icons.expand_less, + color: theme.colorScheme.onSurfaceVariant, + ), + onPressed: () => _setExpanded(!_expanded), + ), + ], + ), + ); + } +} diff --git a/lib/pangea/activity_planner/activity_participant_indicator.dart b/lib/pangea/activity_planner/activity_participant_indicator.dart new file mode 100644 index 000000000..e7685bae5 --- /dev/null +++ b/lib/pangea/activity_planner/activity_participant_indicator.dart @@ -0,0 +1,81 @@ +import 'package:flutter/material.dart'; + +import 'package:fluffychat/l10n/l10n.dart'; +import 'package:fluffychat/pangea/activity_planner/activity_role_model.dart'; +import 'package:fluffychat/utils/string_color.dart'; +import 'package:fluffychat/widgets/hover_builder.dart'; + +class ActivityParticipantIndicator extends StatelessWidget { + final bool selected; + + final ActivityRoleModel? role; + final String? displayname; + + final VoidCallback? onTap; + + const ActivityParticipantIndicator({ + super.key, + this.selected = false, + this.role, + this.displayname, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return AbsorbPointer( + absorbing: onTap == null, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: onTap, + child: HoverBuilder( + builder: (context, hovered) { + return Opacity( + opacity: onTap == null ? 0.7 : 1.0, + child: Container( + padding: const EdgeInsets.symmetric( + vertical: 4.0, + horizontal: 8.0, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8.0), + color: hovered || selected + ? theme.colorScheme.primaryContainer.withAlpha( + selected ? 100 : 50, + ) + : Colors.transparent, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircleAvatar( + radius: 30.0, + backgroundColor: theme.colorScheme.primaryContainer, + ), + Text( + role?.role ?? L10n.of(context).participant, + style: const TextStyle( + fontSize: 12.0, + ), + ), + Text( + displayname ?? L10n.of(context).openRoleLabel, + style: TextStyle( + fontSize: 12.0, + color: displayname?.lightColorAvatar ?? + role?.role?.lightColorAvatar, + ), + ), + ], + ), + ), + ); + }, + ), + ), + ), + ); + } +} diff --git a/lib/pangea/activity_planner/activity_pinned_message.dart b/lib/pangea/activity_planner/activity_pinned_message.dart new file mode 100644 index 000000000..5623f8d9d --- /dev/null +++ b/lib/pangea/activity_planner/activity_pinned_message.dart @@ -0,0 +1,187 @@ +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/app_config.dart'; +import 'package:fluffychat/config/themes.dart'; +import 'package:fluffychat/l10n/l10n.dart'; +import 'package:fluffychat/pages/chat/chat.dart'; +import 'package:fluffychat/pages/chat/chat_app_bar_list_tile.dart'; +import 'package:fluffychat/pangea/activity_planner/activity_room_extension.dart'; +import 'package:fluffychat/pangea/activity_suggestions/activity_suggestions_constants.dart'; +import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart'; +import 'package:fluffychat/widgets/future_loading_dialog.dart'; + +class ActivityPinnedMessage extends StatefulWidget { + final ChatController controller; + const ActivityPinnedMessage(this.controller, {super.key}); + + @override + State createState() => ActivityPinnedMessageState(); +} + +class ActivityPinnedMessageState extends State { + bool _showDropdown = false; + + Room get room => widget.controller.room; + + void _scrollToActivity() { + final eventId = widget.controller.timeline?.events + .firstWhereOrNull( + (e) => e.type == PangeaEventTypes.activityPlan, + ) + ?.eventId; + if (eventId == null) return; + widget.controller.scrollToEventId(eventId); + } + + void _setShowDropdown(bool value) { + if (value != _showDropdown) { + setState(() { + _showDropdown = value; + }); + } + } + + Future _finishActivity() async { + final resp = await showFutureLoadingDialog( + context: context, + future: () async { + await room.finishActivity(); + if (mounted) { + _setShowDropdown(false); + } + }, + ); + + if (resp.isError) return; + if (room.activityIsFinished) { + await room.fetchSummaries(); + } + } + + @override + Widget build(BuildContext context) { + if (!room.hasJoinedActivity || room.activityPlan == null) { + return const SizedBox.shrink(); + } + + final theme = Theme.of(context); + final isColumnMode = FluffyThemes.isColumnMode(context); + + return Positioned( + top: 0, + left: 0, + right: 0, + bottom: _showDropdown ? 0 : null, + child: Column( + children: [ + AnimatedContainer( + duration: FluffyThemes.animationDuration, + decoration: BoxDecoration( + color: _showDropdown + ? theme.colorScheme.surfaceContainerHighest + : theme.colorScheme.surface, + ), + child: ChatAppBarListTile( + title: room.activityPlan?.markdown ?? + L10n.of(context).loadingPleaseWait, + leading: const SizedBox(width: 18.0), + trailing: Padding( + padding: const EdgeInsets.only(right: 12.0), + child: room.hasFinishedActivity + ? null + : ElevatedButton( + onPressed: + _showDropdown ? null : () => _setShowDropdown(true), + style: ElevatedButton.styleFrom( + minimumSize: Size.zero, + padding: const EdgeInsets.symmetric( + horizontal: 12.0, + vertical: 4.0, + ), + backgroundColor: theme.colorScheme.onSurface, + foregroundColor: theme.colorScheme.surface, + ), + child: Text( + L10n.of(context).done, + style: const TextStyle( + fontSize: 12.0, + ), + ), + ), + ), + onTap: _scrollToActivity, + ), + ), + AnimatedSize( + duration: FluffyThemes.animationDuration, + curve: Curves.easeInOut, + child: ClipRect( + child: _showDropdown + ? Container( + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + ), + padding: const EdgeInsets.symmetric( + horizontal: 12.0, + vertical: 16.0, + ), + child: Column( + spacing: 12.0, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + L10n.of(context).endActivityDesc, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: isColumnMode ? 16.0 : 12.0, + ), + ), + CachedNetworkImage( + imageUrl: + "${AppConfig.assetsBaseURL}/${ActivitySuggestionsConstants.endActivityAssetPath}", + width: isColumnMode ? 240.0 : 120.0, + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 12.0, + vertical: 8.0, + ), + foregroundColor: theme.colorScheme.onSecondary, + backgroundColor: theme.colorScheme.secondary, + ), + onPressed: _finishActivity, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + L10n.of(context).endActivityTitle, + style: TextStyle( + fontSize: isColumnMode ? 16.0 : 12.0, + ), + ), + ], + ), + ), + ], + ), + ) + : const SizedBox.shrink(), + ), + ), + if (_showDropdown) + Expanded( + child: GestureDetector( + onTap: () => _setShowDropdown(false), + child: Container(color: Colors.black.withAlpha(100)), + ), + ), + ], + ), + ); + } +} diff --git a/lib/pangea/activity_planner/activity_plan_card.dart b/lib/pangea/activity_planner/activity_plan_card.dart index c2fb38eca..64095a26e 100644 --- a/lib/pangea/activity_planner/activity_plan_card.dart +++ b/lib/pangea/activity_planner/activity_plan_card.dart @@ -12,12 +12,14 @@ import 'package:fluffychat/l10n/l10n.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/activity_planner/bookmarked_activities_repo.dart'; -import 'package:fluffychat/pangea/activity_suggestions/activity_room_selection.dart'; +import 'package:fluffychat/pangea/activity_suggestions/activity_suggestion_dialog.dart'; import 'package:fluffychat/pangea/chat_settings/widgets/language_level_dropdown.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; -import 'package:fluffychat/pangea/common/widgets/full_width_dialog.dart'; import 'package:fluffychat/pangea/learning_settings/enums/language_level_type_enum.dart'; +import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart'; +import 'package:fluffychat/widgets/avatar.dart'; import 'package:fluffychat/widgets/future_loading_dialog.dart'; +import 'package:fluffychat/widgets/mxc_image.dart'; class ActivityPlanCard extends StatefulWidget { final VoidCallback regenerate; @@ -70,38 +72,24 @@ class ActivityPlanCardState extends State { } Future _onLaunch() async { - if (widget.controller.room != null && !widget.controller.room!.isSpace) { - final resp = await showFutureLoadingDialog( - context: context, - future: widget.controller.launchToRoom, - ); - if (!resp.isError) { - context.go("/rooms/${widget.controller.room!.id}"); - } - return; - } - - return showDialog( + final resp = await showFutureLoadingDialog( context: context, - builder: (context) { - return FullWidthDialog( - dialogContent: DecoratedBox( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - ), - child: ActivityRoomSelection( - controller: widget.controller, - backButton: IconButton( - onPressed: Navigator.of(context).pop, - icon: const Icon(Icons.close), - ), - ), - ), - maxWidth: 400.0, - maxHeight: 650.0, - ); + future: () async { + if (!widget.controller.room.isSpace) { + throw Exception( + "Cannot launch activity in a non-space room", + ); + } + + await widget.controller.launchToSpace(); + context.go("/rooms?spaceId=${widget.controller.room.id}"); + Navigator.of(context).pop(); }, ); + + if (!resp.isError) { + context.go("/rooms?spaceId=${widget.controller.room.id}"); + } } bool get _isBookmarked => BookmarkedActivitiesRepo.isBookmarked( @@ -134,33 +122,47 @@ class ActivityPlanCardState extends State { ), clipBehavior: Clip.hardEdge, alignment: Alignment.center, - child: widget.controller.imageURL != null || - widget.controller.avatar != null - ? ClipRRect( - borderRadius: BorderRadius.circular(20.0), - child: widget.controller.avatar == null - ? CachedNetworkImage( - fit: BoxFit.cover, - imageUrl: widget.controller.imageURL!, - placeholder: (context, url) { - return const Center( - child: CircularProgressIndicator(), - ); - }, - errorWidget: (context, url, error) { - return const Padding( - padding: EdgeInsets.all(28.0), - ); - }, - ) - : Image.memory( - widget.controller.avatar!, - fit: BoxFit.cover, - ), + child: widget.controller.isLaunching + ? Avatar( + mxContent: widget.controller.room.avatar, + name: widget.controller.room + .getLocalizedDisplayname( + MatrixLocals( + L10n.of(context), + ), + ), + borderRadius: BorderRadius.circular(12.0), + size: 200.0, ) - : const Padding( - padding: EdgeInsets.all(28.0), - ), + : widget.controller.imageURL != null || + widget.controller.avatar != null + ? ClipRRect( + borderRadius: BorderRadius.circular(20.0), + child: widget.controller.avatar == null + ? CachedNetworkImage( + fit: BoxFit.cover, + imageUrl: + widget.controller.imageURL!, + placeholder: (context, url) { + return const Center( + child: + CircularProgressIndicator(), + ); + }, + errorWidget: (context, url, error) { + return const Padding( + padding: EdgeInsets.all(28.0), + ); + }, + ) + : Image.memory( + widget.controller.avatar!, + fit: BoxFit.cover, + ), + ) + : const Padding( + padding: EdgeInsets.all(28.0), + ), ), if (widget.controller.isEditing) InkWell( @@ -184,348 +186,534 @@ class ActivityPlanCardState extends State { padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - const Icon(Icons.event_note_outlined), - const SizedBox(width: itemPadding), - Expanded( - child: widget.controller.isEditing - ? TextField( - controller: - widget.controller.titleController, - decoration: InputDecoration( - labelText: L10n.of(context).activityTitle, + children: widget.controller.isLaunching + ? [ + Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Avatar( + mxContent: widget.controller.room.avatar, + name: widget.controller.room + .getLocalizedDisplayname( + MatrixLocals(L10n.of(context)), + ), + size: 24.0, + borderRadius: BorderRadius.circular(4.0), + ), + const SizedBox(width: itemPadding), + Expanded( + child: Text( + widget.controller.room + .getLocalizedDisplayname( + MatrixLocals(L10n.of(context)), ), - maxLines: null, - ) - : Text( + style: + Theme.of(context).textTheme.bodyLarge, + ), + ), + ], + ), + const SizedBox(height: itemPadding), + Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + widget.controller.updatedActivity.imageURL != + null + ? ClipRRect( + borderRadius: + BorderRadius.circular(4.0), + child: widget.controller.updatedActivity + .imageURL! + .startsWith("mxc") + ? MxcImage( + uri: Uri.parse( + widget + .controller + .updatedActivity + .imageURL!, + ), + width: 24.0, + height: 24.0, + cacheKey: widget.controller + .updatedActivity.bookmarkId, + fit: BoxFit.cover, + ) + : CachedNetworkImage( + imageUrl: widget.controller + .updatedActivity.imageURL!, + fit: BoxFit.cover, + width: 24.0, + height: 24.0, + placeholder: ( + context, + url, + ) => + const Center( + child: + CircularProgressIndicator(), + ), + errorWidget: ( + context, + url, + error, + ) => + const SizedBox(), + ), + ) + : const Icon( + Icons.event_note_outlined, + size: 24.0, + ), + const SizedBox(width: itemPadding), + Expanded( + child: Text( widget.controller.updatedActivity.title, style: Theme.of(context).textTheme.bodyLarge, ), - ), - if (!widget.controller.isEditing) - IconButton( - onPressed: _isBookmarked - ? () => _removeBookmark() - : () => _addBookmark( - widget.controller.updatedActivity, + ), + ], + ), + const SizedBox(height: itemPadding), + Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + const Icon(Icons.groups, size: 24.0), + const SizedBox(width: itemPadding), + Expanded( + child: Text( + L10n.of(context) + .minimumActivityParticipants( + widget.controller.updatedActivity.req + .numberOfParticipants, + ), + style: + Theme.of(context).textTheme.bodyLarge, + ), + ), + ], + ), + const SizedBox(height: itemPadding), + Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + const Icon(Icons.radar, size: 24.0), + const SizedBox(width: itemPadding), + Expanded( + child: Column( + spacing: 4.0, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + L10n.of(context).numberOfActivities, + style: Theme.of(context) + .textTheme + .bodyLarge, ), - icon: Icon( - _isBookmarked - ? Icons.save - : Icons.save_outlined, - ), + NumberCounter( + count: widget.controller.numActivities, + update: + widget.controller.setNumActivities, + min: 1, + max: 5, + ), + ], + ), + ), + ], ), - ], - ), - const SizedBox(height: itemPadding), - Row( - children: [ - Icon( - Symbols.target, - color: Theme.of(context).colorScheme.secondary, - ), - const SizedBox(width: itemPadding), - Expanded( - child: widget.controller.isEditing - ? TextField( - controller: widget.controller - .learningObjectivesController, - decoration: InputDecoration( - labelText: l10n.learningObjectiveLabel, - ), - maxLines: null, - ) - : Text( - widget.controller.updatedActivity - .learningObjective, - style: - Theme.of(context).textTheme.bodyMedium, - ), - ), - ], - ), - const SizedBox(height: itemPadding), - Row( - children: [ - Icon( - Symbols.steps_rounded, - color: Theme.of(context).colorScheme.secondary, - ), - const SizedBox(width: itemPadding), - Expanded( - child: widget.controller.isEditing - ? TextField( - controller: widget - .controller.instructionsController, - decoration: InputDecoration( - labelText: l10n.instructions, - ), - maxLines: null, - ) - : Text( - widget.controller.updatedActivity - .instructions, - style: - Theme.of(context).textTheme.bodyMedium, - ), - ), - ], - ), - const SizedBox(height: itemPadding), - Row( - children: [ - Icon( - Icons.school_outlined, - color: Theme.of(context).colorScheme.secondary, - ), - const SizedBox(width: itemPadding), - Expanded( - child: widget.controller.isEditing - ? LanguageLevelDropdown( - initialLevel: - widget.controller.languageLevel, - onChanged: - widget.controller.setLanguageLevel, - ) - : Text( - widget.controller.updatedActivity.req - .cefrLevel - .title(context), - style: - Theme.of(context).textTheme.bodyMedium, - ), - ), - ], - ), - const SizedBox(height: itemPadding), - if (widget.controller.vocab.isNotEmpty) ...[ - Row( - children: [ - Icon( - Symbols.dictionary, - color: Theme.of(context).colorScheme.secondary, - ), - const SizedBox(width: itemPadding), - Expanded( - child: Wrap( - spacing: 4.0, - runSpacing: 4.0, - children: List.generate( - widget.controller.vocab.length, - (int index) { - return widget.controller.isEditing - ? Chip( - label: Text( - widget - .controller.vocab[index].lemma, - ), - onDeleted: () => widget.controller - .removeVocab(index), - backgroundColor: Colors.transparent, - visualDensity: VisualDensity.compact, - shape: const StadiumBorder( - side: BorderSide( - color: Colors.transparent, - ), - ), - ) - : Chip( - label: Text( - widget - .controller.vocab[index].lemma, - ), - backgroundColor: Colors.transparent, - visualDensity: VisualDensity.compact, - shape: const StadiumBorder( - side: BorderSide( - color: Colors.transparent, - ), - ), - ); - }).toList(), - ), - ), - ], - ), - ], - if (widget.controller.isEditing) ...[ - const SizedBox(height: itemPadding), - Padding( - padding: const EdgeInsets.only(top: itemPadding), - child: Row( - children: [ - Expanded( - child: TextField( - controller: widget.controller.vocabController, - decoration: InputDecoration( - labelText: l10n.addVocabulary, - ), - onSubmitted: (value) { - widget.controller.addVocab(); - }, + const SizedBox(height: itemPadding), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: + theme.colorScheme.primaryContainer, + foregroundColor: + theme.colorScheme.onPrimaryContainer, + padding: const EdgeInsets.symmetric( + horizontal: 12.0, ), ), - IconButton( - icon: const Icon(Icons.add), - onPressed: widget.controller.addVocab, + onPressed: _onLaunch, + child: Row( + children: [ + const Icon(Icons.send_outlined), + Expanded( + child: Text( + L10n.of(context).launchToSpace, + textAlign: TextAlign.center, + ), + ), + ], + ), + ), + ] + : [ + Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + const Icon(Icons.event_note_outlined), + const SizedBox(width: itemPadding), + Expanded( + child: widget.controller.isEditing + ? TextField( + controller: + widget.controller.titleController, + decoration: InputDecoration( + labelText: + L10n.of(context).activityTitle, + ), + maxLines: null, + ) + : Text( + widget + .controller.updatedActivity.title, + style: Theme.of(context) + .textTheme + .bodyLarge, + ), + ), + if (!widget.controller.isEditing) + IconButton( + onPressed: _isBookmarked + ? () => _removeBookmark() + : () => _addBookmark( + widget.controller.updatedActivity, + ), + icon: Icon( + _isBookmarked + ? Icons.save + : Icons.save_outlined, + ), + ), + ], + ), + const SizedBox(height: itemPadding), + Row( + children: [ + Icon( + Symbols.target, + color: + Theme.of(context).colorScheme.secondary, + ), + const SizedBox(width: itemPadding), + Expanded( + child: widget.controller.isEditing + ? TextField( + controller: widget.controller + .learningObjectivesController, + decoration: InputDecoration( + labelText: + l10n.learningObjectiveLabel, + ), + maxLines: null, + ) + : Text( + widget.controller.updatedActivity + .learningObjective, + style: Theme.of(context) + .textTheme + .bodyMedium, + ), + ), + ], + ), + const SizedBox(height: itemPadding), + Row( + children: [ + Icon( + Symbols.steps_rounded, + color: + Theme.of(context).colorScheme.secondary, + ), + const SizedBox(width: itemPadding), + Expanded( + child: widget.controller.isEditing + ? TextField( + controller: widget.controller + .instructionsController, + decoration: InputDecoration( + labelText: l10n.instructions, + ), + maxLines: null, + ) + : Text( + widget.controller.updatedActivity + .instructions, + style: Theme.of(context) + .textTheme + .bodyMedium, + ), + ), + ], + ), + const SizedBox(height: itemPadding), + Row( + children: [ + Icon( + Icons.school_outlined, + color: + Theme.of(context).colorScheme.secondary, + ), + const SizedBox(width: itemPadding), + Expanded( + child: widget.controller.isEditing + ? LanguageLevelDropdown( + initialLevel: + widget.controller.languageLevel, + onChanged: widget + .controller.setLanguageLevel, + ) + : Text( + widget.controller.updatedActivity.req + .cefrLevel + .title(context), + style: Theme.of(context) + .textTheme + .bodyMedium, + ), + ), + ], + ), + const SizedBox(height: itemPadding), + if (widget.controller.vocab.isNotEmpty) ...[ + Row( + children: [ + Icon( + Symbols.dictionary, + color: + Theme.of(context).colorScheme.secondary, + ), + const SizedBox(width: itemPadding), + Expanded( + child: Wrap( + spacing: 4.0, + runSpacing: 4.0, + children: List.generate( + widget.controller.vocab.length, + (int index) { + return widget.controller.isEditing + ? Chip( + label: Text( + widget.controller.vocab[index] + .lemma, + ), + onDeleted: () => widget + .controller + .removeVocab(index), + backgroundColor: + Colors.transparent, + visualDensity: + VisualDensity.compact, + shape: const StadiumBorder( + side: BorderSide( + color: Colors.transparent, + ), + ), + ) + : Chip( + label: Text( + widget.controller.vocab[index] + .lemma, + ), + backgroundColor: + Colors.transparent, + visualDensity: + VisualDensity.compact, + shape: const StadiumBorder( + side: BorderSide( + color: Colors.transparent, + ), + ), + ); + }).toList(), + ), + ), + ], ), ], - ), - ), - ], - const SizedBox(height: itemPadding), - widget.controller.isEditing - ? Row( - spacing: 12.0, - children: [ - Expanded( - child: ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: - theme.colorScheme.primaryContainer, - foregroundColor: - theme.colorScheme.onPrimaryContainer, - padding: const EdgeInsets.symmetric( - horizontal: 12.0, - ), - ), - onPressed: widget.controller.saveEdits, - child: Row( - children: [ - const Icon(Icons.save), - Expanded( - child: Text( - L10n.of(context).save, - textAlign: TextAlign.center, - ), - ), - ], - ), - ), - ), - Expanded( - child: ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: - theme.colorScheme.primaryContainer, - foregroundColor: - theme.colorScheme.onPrimaryContainer, - padding: const EdgeInsets.symmetric( - horizontal: 12.0, - ), - ), - onPressed: widget.controller.clearEdits, - child: Row( - children: [ - const Icon(Icons.cancel), - Expanded( - child: Text( - L10n.of(context).cancel, - textAlign: TextAlign.center, - ), - ), - ], - ), - ), - ), - ], - ) - : Column( - spacing: 12.0, - children: [ - Row( - spacing: 12.0, + if (widget.controller.isEditing) ...[ + const SizedBox(height: itemPadding), + Padding( + padding: + const EdgeInsets.only(top: itemPadding), + child: Row( children: [ Expanded( - child: ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: theme - .colorScheme.primaryContainer, - foregroundColor: theme - .colorScheme.onPrimaryContainer, - padding: const EdgeInsets.symmetric( - horizontal: 12.0, - ), + child: TextField( + controller: + widget.controller.vocabController, + decoration: InputDecoration( + labelText: l10n.addVocabulary, ), - child: Row( - children: [ - const Icon(Icons.edit), - Expanded( - child: Text( - L10n.of(context).edit, - textAlign: TextAlign.center, - ), - ), - ], - ), - onPressed: () => - widget.controller.setEditing(true), + onSubmitted: (value) { + widget.controller.addVocab(); + }, ), ), - Expanded( - child: ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: theme - .colorScheme.primaryContainer, - foregroundColor: theme - .colorScheme.onPrimaryContainer, - padding: const EdgeInsets.symmetric( - horizontal: 12.0, - ), - ), - onPressed: widget.regenerate, - child: Row( - children: [ - const Icon(Icons.lightbulb_outline), - Expanded( - child: Text( - L10n.of(context).regenerate, - textAlign: TextAlign.center, - ), - ), - ], - ), - ), + IconButton( + icon: const Icon(Icons.add), + onPressed: widget.controller.addVocab, ), ], ), - Row( - children: [ - Expanded( - child: ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: theme - .colorScheme.primaryContainer, - foregroundColor: theme - .colorScheme.onPrimaryContainer, - padding: const EdgeInsets.symmetric( - horizontal: 12.0, - ), - ), - onPressed: _onLaunch, - child: Row( - children: [ - const Icon(Icons.send), - Expanded( - child: Text( - L10n.of(context) - .launchActivityButton, - textAlign: TextAlign.center, - ), - ), - ], + ), + const SizedBox(height: itemPadding), + Row( + spacing: 12.0, + children: [ + Expanded( + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: + theme.colorScheme.primaryContainer, + foregroundColor: theme + .colorScheme.onPrimaryContainer, + padding: const EdgeInsets.symmetric( + horizontal: 12.0, ), ), + onPressed: widget.controller.saveEdits, + child: Row( + children: [ + const Icon(Icons.save), + Expanded( + child: Text( + L10n.of(context).save, + textAlign: TextAlign.center, + ), + ), + ], + ), ), - ], - ), - ], - ), - ], + ), + Expanded( + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: + theme.colorScheme.primaryContainer, + foregroundColor: theme + .colorScheme.onPrimaryContainer, + padding: const EdgeInsets.symmetric( + horizontal: 12.0, + ), + ), + onPressed: widget.controller.clearEdits, + child: Row( + children: [ + const Icon(Icons.cancel), + Expanded( + child: Text( + L10n.of(context).cancel, + textAlign: TextAlign.center, + ), + ), + ], + ), + ), + ), + ], + ), + ] else + Column( + spacing: 12.0, + children: [ + Row( + spacing: 12.0, + children: [ + Expanded( + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: theme + .colorScheme.primaryContainer, + foregroundColor: theme + .colorScheme.onPrimaryContainer, + padding: const EdgeInsets.symmetric( + horizontal: 12.0, + ), + ), + onPressed: + widget.controller.startEditing, + child: Row( + children: [ + const Icon(Icons.edit), + Expanded( + child: Text( + L10n.of(context).edit, + textAlign: TextAlign.center, + ), + ), + ], + ), + ), + ), + Expanded( + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: theme + .colorScheme.primaryContainer, + foregroundColor: theme + .colorScheme.onPrimaryContainer, + padding: const EdgeInsets.symmetric( + horizontal: 12.0, + ), + ), + onPressed: widget.regenerate, + child: Row( + children: [ + const Icon( + Icons.lightbulb_outline, + ), + Expanded( + child: Text( + L10n.of(context).regenerate, + textAlign: TextAlign.center, + ), + ), + ], + ), + ), + ), + ], + ), + Row( + children: [ + Expanded( + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: theme + .colorScheme.primaryContainer, + foregroundColor: theme + .colorScheme.onPrimaryContainer, + padding: const EdgeInsets.symmetric( + horizontal: 12.0, + ), + ), + onPressed: () { + widget.controller.setLaunchState( + ActivityLaunchState.launching, + ); + }, + child: Row( + children: [ + const Icon(Icons.save_outlined), + Expanded( + child: Text( + L10n.of(context) + .saveAndLaunch, + textAlign: TextAlign.center, + ), + ), + ], + ), + ), + ), + ], + ), + ], + ), + ], ), ), ], diff --git a/lib/pangea/activity_planner/activity_planner_builder.dart b/lib/pangea/activity_planner/activity_planner_builder.dart index acdeb25f3..e50203ded 100644 --- a/lib/pangea/activity_planner/activity_planner_builder.dart +++ b/lib/pangea/activity_planner/activity_planner_builder.dart @@ -1,5 +1,5 @@ import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart' hide Visibility; import 'package:collection/collection.dart'; import 'package:http/http.dart' as http; @@ -8,18 +8,27 @@ import 'package:matrix/matrix.dart'; import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart'; import 'package:fluffychat/pangea/activity_planner/activity_plan_request.dart'; +import 'package:fluffychat/pangea/activity_planner/activity_room_extension.dart'; import 'package:fluffychat/pangea/activity_planner/bookmarked_activities_repo.dart'; +import 'package:fluffychat/pangea/chat/constants/default_power_level.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'; import 'package:fluffychat/pangea/learning_settings/enums/language_level_type_enum.dart'; import 'package:fluffychat/utils/client_download_content_extension.dart'; import 'package:fluffychat/utils/file_selector.dart'; import 'package:fluffychat/widgets/matrix.dart'; +enum ActivityLaunchState { + base, + editing, + launching, +} + class ActivityPlannerBuilder extends StatefulWidget { final ActivityPlanModel initialActivity; final String? initialFilename; - final Room? room; + final Room room; final Widget Function(ActivityPlannerBuilderState) builder; @@ -27,7 +36,7 @@ class ActivityPlannerBuilder extends StatefulWidget { super.key, required this.initialActivity, this.initialFilename, - this.room, + required this.room, required this.builder, }); @@ -36,11 +45,13 @@ class ActivityPlannerBuilder extends StatefulWidget { } class ActivityPlannerBuilderState extends State { - bool isEditing = false; + ActivityLaunchState launchState = ActivityLaunchState.base; Uint8List? avatar; String? imageURL; String? filename; + int numActivities = 1; + final TextEditingController titleController = TextEditingController(); final TextEditingController instructionsController = TextEditingController(); final TextEditingController vocabController = TextEditingController(); @@ -69,7 +80,10 @@ class ActivityPlannerBuilderState extends State { super.dispose(); } - Room? get room => widget.room; + Room get room => widget.room; + + bool get isEditing => launchState == ActivityLaunchState.editing; + bool get isLaunching => launchState == ActivityLaunchState.launching; ActivityPlanRequest get updatedRequest { final int participants = int.tryParse(participantsController.text.trim()) ?? @@ -135,8 +149,10 @@ class ActivityPlannerBuilderState extends State { if (mounted) setState(() {}); } - void setEditing(bool editting) { - isEditing = editting; + void startEditing() => setLaunchState(ActivityLaunchState.editing); + + void setLaunchState(ActivityLaunchState state) { + launchState = state; if (mounted) setState(() {}); } @@ -178,6 +194,10 @@ class ActivityPlannerBuilderState extends State { } } + void setNumActivities(int count) { + if (mounted) setState(() => numActivities = count); + } + Future _setAvatarByURL(String url) async { try { if (avatar == null) { @@ -223,7 +243,7 @@ class ActivityPlannerBuilderState extends State { Future saveEdits() async { if (!formKey.currentState!.validate()) return; await updateImageURL(); - setEditing(false); + setLaunchState(ActivityLaunchState.base); await BookmarkedActivitiesRepo.remove(widget.initialActivity.bookmarkId); await BookmarkedActivitiesRepo.save(updatedActivity); @@ -232,20 +252,87 @@ class ActivityPlannerBuilderState extends State { Future clearEdits() async { await resetActivity(); - if (mounted) { - setState(() { - isEditing = false; - }); + setLaunchState(ActivityLaunchState.base); + } + + Future launchToSpace() async { + final List activityRoomIDs = []; + try { + await Future.wait( + List.generate(numActivities, (i) async { + final id = await _launchActivityRoom(i); + activityRoomIDs.add(id); + }), + ); + } catch (e) { + _cleanupFailedLaunch(activityRoomIDs); + rethrow; } } - Future launchToRoom() async { - if (room == null || room!.isSpace) return; - return room?.sendActivityPlan( + Future _launchActivityRoom(int index) async { + await updateImageURL(); + final roomID = await Matrix.of(context).client.createGroupChat( + visibility: Visibility.private, + groupName: "${updatedActivity.title} ${index + 1}", + initialState: [ + if (imageURL != null) + StateEvent( + type: EventTypes.RoomAvatar, + content: {'url': imageURL}, + ), + RoomDefaults.defaultPowerLevels( + Matrix.of(context).client.userID!, + ), + await Matrix.of(context).client.pangeaJoinRules( + 'knock_restricted', + allow: [ + { + "type": "m.room_membership", + "room_id": room.id, + } + ], + ), + ], + enableEncryption: false, + ); + + Room? activityRoom = room.client.getRoomById(roomID); + if (activityRoom == null) { + await room.client.waitForRoomInSync(roomID); + activityRoom = room.client.getRoomById(roomID); + if (activityRoom == null) { + throw Exception("Failed to create activity room"); + } + } + + await room.addToSpace(activityRoom.id); + if (activityRoom.pangeaSpaceParents.isEmpty) { + await room.client.waitForRoomInSync(activityRoom.id); + } + + await activityRoom.sendActivityPlan( updatedActivity, avatar: avatar, filename: filename, ); + + return activityRoom.id; + } + + Future _cleanupFailedLaunch(List roomIds) async { + final futures = roomIds.map((id) async { + final room = Matrix.of(context).client.getRoomById(id); + if (room == null) return; + + try { + await room.leave(); + } catch (e) { + debugPrint("Failed to leave room $id: $e"); + } + }); + + await Future.wait(futures); } @override diff --git a/lib/pangea/activity_planner/activity_planner_page.dart b/lib/pangea/activity_planner/activity_planner_page.dart index a2ff44473..53c824f72 100644 --- a/lib/pangea/activity_planner/activity_planner_page.dart +++ b/lib/pangea/activity_planner/activity_planner_page.dart @@ -11,6 +11,7 @@ import 'package:fluffychat/pangea/activity_planner/bookmarked_activity_list.dart import 'package:fluffychat/pangea/activity_suggestions/activity_suggestions_area.dart'; import 'package:fluffychat/pangea/activity_suggestions/activity_suggestions_constants.dart'; import 'package:fluffychat/pangea/common/widgets/customized_svg.dart'; +import 'package:fluffychat/pangea/common/widgets/error_indicator.dart'; import 'package:fluffychat/widgets/matrix.dart'; enum PageMode { @@ -38,23 +39,27 @@ class ActivityPlannerPageState extends State { @override Widget build(BuildContext context) { final theme = Theme.of(context); - Widget body = const SizedBox(); + Widget? body; switch (pageMode) { case PageMode.savedActivities: - body = BookmarkedActivitiesList( - room: room, - controller: this, - ); + if (room != null) { + body = BookmarkedActivitiesList( + room: room!, + controller: this, + ); + } break; case PageMode.featuredActivities: - body = Expanded( - child: SingleChildScrollView( - child: ActivitySuggestionsArea( - scrollDirection: Axis.vertical, - room: room, + if (room != null) { + body = Expanded( + child: SingleChildScrollView( + child: ActivitySuggestionsArea( + scrollDirection: Axis.vertical, + room: room!, + ), ), - ), - ); + ); + } break; } @@ -130,7 +135,7 @@ class ActivityPlannerPageState extends State { height: 24.0, width: 24.0, ), - Text(L10n.of(context).createActivity), + Text(L10n.of(context).createActivityPlan), ], ), selected: false, @@ -141,7 +146,10 @@ class ActivityPlannerPageState extends State { ], ), ), - body, + body ?? + ErrorIndicator( + message: L10n.of(context).oopsSomethingWentWrong, + ), ], ), ), diff --git a/lib/pangea/activity_planner/activity_results_carousel.dart b/lib/pangea/activity_planner/activity_results_carousel.dart new file mode 100644 index 000000000..65266d25e --- /dev/null +++ b/lib/pangea/activity_planner/activity_results_carousel.dart @@ -0,0 +1,139 @@ +import 'package:flutter/material.dart'; + +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/config/themes.dart'; +import 'package:fluffychat/pangea/activity_planner/activity_role_model.dart'; +import 'package:fluffychat/pangea/activity_summary/activity_summary_response_model.dart'; +import 'package:fluffychat/pangea/common/widgets/pressable_button.dart'; +import 'package:fluffychat/widgets/avatar.dart'; + +class ActivityResultsCarousel extends StatelessWidget { + final ActivityRoleModel selectedRole; + final User user; + final ParticipantSummaryModel summary; + + final VoidCallback? moveLeft; + final VoidCallback? moveRight; + + const ActivityResultsCarousel({ + super.key, + required this.selectedRole, + required this.moveLeft, + required this.moveRight, + required this.user, + required this.summary, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isColumnMode = FluffyThemes.isColumnMode(context); + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.arrow_back_ios_new), + onPressed: moveLeft, + ), + Padding( + padding: const EdgeInsets.all(12.0), + child: PressableButton( + onPressed: null, + 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), + ), + width: isColumnMode ? 225.0 : 175.0, + child: Container( + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainer, + borderRadius: BorderRadius.circular(24.0), + ), + padding: const EdgeInsets.all(12.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Align( + alignment: Alignment.center, + child: Avatar( + size: isColumnMode ? 60.0 : 40.0, + mxContent: user.avatarUrl, + name: user.calcDisplayname(), + userId: selectedRole.userId, + ), + ), + const SizedBox(height: 4.0), + Text( + selectedRole.role != null + ? "${selectedRole.role!} | ${selectedRole.userId.localpart}" + : "${selectedRole.userId.localpart}", + style: TextStyle(fontSize: isColumnMode ? 16.0 : 12.0), + ), + const SizedBox(height: 10.0), + Text( + summary.feedback, + style: TextStyle(fontSize: isColumnMode ? 12.0 : 8.0), + ), + const SizedBox(height: 10.0), + Wrap( + spacing: 8.0, + runSpacing: 8.0, + children: [ + Container( + padding: const EdgeInsets.all(4.0), + decoration: BoxDecoration( + color: theme.colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(20), + ), + child: Row( + spacing: 4.0, + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.school, size: 12.0), + Text( + summary.cefrLevel, + style: TextStyle( + fontSize: isColumnMode ? 12.0 : 8.0, + ), + ), + ], + ), + ), + ...summary.superlatives.map( + (sup) => Container( + padding: const EdgeInsets.all(4.0), + decoration: BoxDecoration( + color: theme.colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(20), + ), + child: Text( + sup, + style: TextStyle( + fontSize: isColumnMode ? 12.0 : 8.0, + ), + ), + ), + ), + ], + ), + ], + ), + ), + ), + ), + ), + IconButton( + icon: const Icon(Icons.arrow_forward_ios), + onPressed: moveRight, + ), + ], + ); + } +} diff --git a/lib/pangea/activity_planner/activity_role_model.dart b/lib/pangea/activity_planner/activity_role_model.dart new file mode 100644 index 000000000..76110b1b0 --- /dev/null +++ b/lib/pangea/activity_planner/activity_role_model.dart @@ -0,0 +1,53 @@ +class ActivityRoleModel { + final String userId; + final String? role; + DateTime? finishedAt; + DateTime? archivedAt; + + ActivityRoleModel({ + required this.userId, + this.role, + this.finishedAt, + this.archivedAt, + }); + + bool get isFinished => finishedAt != null; + + bool get isArchived => archivedAt != null; + + factory ActivityRoleModel.fromJson(Map json) { + return ActivityRoleModel( + userId: json['userId'], + role: json['role'], + finishedAt: json['finishedAt'] != null + ? DateTime.parse(json['finishedAt']) + : null, + archivedAt: json['archivedAt'] != null + ? DateTime.parse(json['archivedAt']) + : null, + ); + } + + Map toJson() { + return { + 'userId': userId, + 'role': role, + 'finishedAt': finishedAt?.toIso8601String(), + 'archivedAt': archivedAt?.toIso8601String(), + }; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is ActivityRoleModel && + other.userId == userId && + other.role == role && + other.finishedAt == finishedAt; + } + + @override + int get hashCode => + userId.hashCode ^ role.hashCode ^ (finishedAt?.hashCode ?? 0); +} diff --git a/lib/pangea/activity_planner/activity_room_extension.dart b/lib/pangea/activity_planner/activity_room_extension.dart new file mode 100644 index 000000000..dcfad1a46 --- /dev/null +++ b/lib/pangea/activity_planner/activity_room_extension.dart @@ -0,0 +1,220 @@ +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart'; +import 'package:fluffychat/pangea/activity_planner/activity_role_model.dart'; +import 'package:fluffychat/pangea/activity_planner/bookmarked_activities_repo.dart'; +import 'package:fluffychat/pangea/activity_summary/activity_summary_repo.dart'; +import 'package:fluffychat/pangea/activity_summary/activity_summary_request_model.dart'; +import 'package:fluffychat/pangea/activity_summary/activity_summary_response_model.dart'; +import 'package:fluffychat/pangea/chat_settings/utils/download_chat.dart'; +import 'package:fluffychat/pangea/common/utils/error_handler.dart'; +import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart'; +import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart'; + +extension ActivityRoomExtension on Room { + Future sendActivityPlan( + ActivityPlanModel activity, { + Uint8List? avatar, + String? filename, + }) async { + BookmarkedActivitiesRepo.save(activity); + + if (canChangeStateEvent(PangeaEventTypes.activityPlan)) { + await client.setRoomStateWithKey( + id, + PangeaEventTypes.activityPlan, + "", + activity.toJson(), + ); + } + } + + Future setActivityRole({ + String? role, + }) async { + await client.setRoomStateWithKey( + id, + PangeaEventTypes.activityRole, + client.userID!, + ActivityRoleModel( + userId: client.userID!, + role: role, + ).toJson(), + ); + } + + Future finishActivity() async { + final role = activityRole(client.userID!); + if (role == null) return; + + role.finishedAt = DateTime.now(); + final syncFuture = client.waitForRoomInSync(id); + await client.setRoomStateWithKey( + id, + PangeaEventTypes.activityRole, + client.userID!, + role.toJson(), + ); + await syncFuture; + } + + Future setActivitySummary( + ActivitySummaryResponseModel summary, + ) async { + await client.setRoomStateWithKey( + id, + PangeaEventTypes.activitySummary, + "", + summary.toJson(), + ); + } + + Future fetchSummaries() async { + if (activitySummary != null) return; + + final events = await getAllEvents(this); + final List messages = []; + for (final event in events) { + if (event.type != EventTypes.Message || + event.messageType != MessageTypes.Text) { + continue; + } + + final timeline = this.timeline ?? await getTimeline(); + final pangeaMessage = PangeaMessageEvent( + event: event, + timeline: timeline, + ownMessage: client.userID == event.senderId, + ); + + final activityMessage = ActivitySummaryResultsMessage( + userId: event.senderId, + sent: pangeaMessage.originalSent?.text ?? event.body, + written: pangeaMessage.originalWrittenContent, + time: event.originServerTs, + tool: [ + if (pangeaMessage.originalSent?.choreo?.includedIT == true) "it", + if (pangeaMessage.originalSent?.choreo?.includedIGC == true) "igc", + ], + ); + + messages.add(activityMessage); + } + + final resp = await ActivitySummaryRepo.get( + ActivitySummaryRequestModel( + activity: activityPlan!, + activityResults: messages, + contentFeedback: [], + ), + ); + + await setActivitySummary(resp); + } + + Future archiveActivity() async { + final role = activityRole(client.userID!); + if (role == null) return; + + role.archivedAt = DateTime.now(); + await client.setRoomStateWithKey( + id, + PangeaEventTypes.activityRole, + client.userID!, + role.toJson(), + ); + } + + ActivityPlanModel? get activityPlan { + final stateEvent = getState(PangeaEventTypes.activityPlan); + if (stateEvent == null) return null; + + try { + return ActivityPlanModel.fromJson(stateEvent.content); + } catch (e, s) { + ErrorHandler.logError( + e: e, + s: s, + data: { + "roomID": id, + "stateEvent": stateEvent.content, + }, + ); + return null; + } + } + + ActivityRoleModel? activityRole(String userId) { + final stateEvent = getState(PangeaEventTypes.activityRole, userId); + if (stateEvent == null) return null; + + try { + return ActivityRoleModel.fromJson(stateEvent.content); + } catch (e, s) { + ErrorHandler.logError( + e: e, + s: s, + data: { + "roomID": id, + "userId": userId, + "stateEvent": stateEvent.content, + }, + ); + return null; + } + } + + ActivitySummaryResponseModel? get activitySummary { + final stateEvent = getState(PangeaEventTypes.activitySummary); + if (stateEvent == null) return null; + + try { + return ActivitySummaryResponseModel.fromJson(stateEvent.content); + } catch (e, s) { + ErrorHandler.logError( + e: e, + s: s, + data: { + "roomID": id, + "stateEvent": stateEvent.content, + }, + ); + return null; + } + } + + List get _activityRoleEvents { + return states[PangeaEventTypes.activityRole]?.values.toList() ?? []; + } + + List get activityRoles { + return _activityRoleEvents + .map((r) => ActivityRoleModel.fromJson(r.content)) + .toList(); + } + + bool get hasJoinedActivity { + return activityPlan == null || activityRole(client.userID!) != null; + } + + bool get hasFinishedActivity { + final role = activityRole(client.userID!); + return role != null && role.isFinished; + } + + bool get activityIsFinished { + return activityRoles.isNotEmpty && activityRoles.every((r) => r.isFinished); + } + + int? get numberOfParticipants { + return activityPlan?.req.numberOfParticipants; + } + + int get remainingRoles { + if (numberOfParticipants == null) return 0; + return max(0, numberOfParticipants! - activityRoles.length); + } +} diff --git a/lib/pangea/activity_planner/activity_status_message.dart b/lib/pangea/activity_planner/activity_status_message.dart new file mode 100644 index 000000000..3f5e3303e --- /dev/null +++ b/lib/pangea/activity_planner/activity_status_message.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; + +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/config/themes.dart'; +import 'package:fluffychat/pangea/activity_planner/activity_finished_status_message.dart'; +import 'package:fluffychat/pangea/activity_planner/activity_room_extension.dart'; +import 'package:fluffychat/pangea/activity_planner/activity_unfinished_status_message.dart'; + +class ActivityStatusMessage extends StatefulWidget { + final Room room; + + const ActivityStatusMessage({ + super.key, + required this.room, + }); + + @override + ActivityStatusMessageState createState() => ActivityStatusMessageState(); +} + +class ActivityStatusMessageState extends State { + @override + void initState() { + super.initState(); + + if (widget.room.activityIsFinished && widget.room.activitySummary == null) { + widget.room.fetchSummaries().then((_) { + if (mounted) setState(() {}); + }); + } + } + + @override + Widget build(BuildContext context) { + return Positioned( + left: 0, + right: 0, + bottom: 0, + child: Material( + child: AnimatedSize( + duration: FluffyThemes.animationDuration, + child: !widget.room.hasJoinedActivity || + widget.room.activityIsFinished + ? Padding( + padding: EdgeInsets.only( + bottom: FluffyThemes.isColumnMode(context) ? 32.0 : 16.0, + left: 16.0, + right: 16.0, + ), + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.8, + ), + child: SingleChildScrollView( + child: widget.room.activityIsFinished + ? ActivityFinishedStatusMessage(room: widget.room) + : ActivityUnfinishedStatusMessage(room: widget.room), + ), + ), + ) + : const SizedBox.shrink(), + ), + ), + ); + } +} diff --git a/lib/pangea/activity_planner/activity_unfinished_status_message.dart b/lib/pangea/activity_planner/activity_unfinished_status_message.dart new file mode 100644 index 000000000..50ad48920 --- /dev/null +++ b/lib/pangea/activity_planner/activity_unfinished_status_message.dart @@ -0,0 +1,96 @@ +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/pangea/activity_planner/activity_participant_indicator.dart'; +import 'package:fluffychat/pangea/activity_planner/activity_room_extension.dart'; +import 'package:fluffychat/widgets/future_loading_dialog.dart'; + +class ActivityUnfinishedStatusMessage extends StatefulWidget { + final Room room; + const ActivityUnfinishedStatusMessage({ + super.key, + required this.room, + }); + + @override + ActivityUnfinishedStatusMessageState createState() => + ActivityUnfinishedStatusMessageState(); +} + +class ActivityUnfinishedStatusMessageState + extends State { + int? _selectedRole; + + void _selectRole(int role) { + if (_selectedRole == role) return; + if (mounted) setState(() => _selectedRole = role); + } + + @override + Widget build(BuildContext context) { + final unassignedRoles = widget.room.remainingRoles; + + final theme = Theme.of(context); + final isColumnMode = FluffyThemes.isColumnMode(context); + + return Column( + children: [ + if (unassignedRoles > 0) + Wrap( + spacing: 12.0, + runSpacing: 12.0, + children: List.generate(unassignedRoles, (index) { + return ActivityParticipantIndicator( + selected: _selectedRole == index, + onTap: () => _selectRole(index), + ); + }), + ), + const SizedBox(height: 16.0), + Text( + unassignedRoles > 0 + ? L10n.of(context).unjoinedActivityMessage + : L10n.of(context).fullActivityMessage, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: isColumnMode ? 16.0 : 12.0, + ), + ), + const SizedBox(height: 16.0), + if (unassignedRoles > 0) + ElevatedButton( + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 12.0, + vertical: 8.0, + ), + foregroundColor: theme.colorScheme.onPrimaryContainer, + backgroundColor: theme.colorScheme.primaryContainer, + ), + onPressed: _selectedRole != null + ? () { + showFutureLoadingDialog( + context: context, + future: () => widget.room.setActivityRole(), + ); + } + : null, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + L10n.of(context).confirmRole, + style: TextStyle( + fontSize: isColumnMode ? 16.0 : 12.0, + ), + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/pangea/activity_planner/bookmarked_activity_list.dart b/lib/pangea/activity_planner/bookmarked_activity_list.dart index 9dad67afc..b706fe291 100644 --- a/lib/pangea/activity_planner/bookmarked_activity_list.dart +++ b/lib/pangea/activity_planner/bookmarked_activity_list.dart @@ -12,7 +12,7 @@ import 'package:fluffychat/pangea/activity_suggestions/activity_suggestion_card. import 'package:fluffychat/pangea/activity_suggestions/activity_suggestion_dialog.dart'; class BookmarkedActivitiesList extends StatefulWidget { - final Room? room; + final Room room; final ActivityPlannerPageState controller; diff --git a/lib/pangea/activity_suggestions/activity_room_selection.dart b/lib/pangea/activity_suggestions/activity_room_selection.dart deleted file mode 100644 index d0d91a78c..000000000 --- a/lib/pangea/activity_suggestions/activity_room_selection.dart +++ /dev/null @@ -1,628 +0,0 @@ -import 'dart:typed_data'; - -import 'package:flutter/material.dart'; - -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:collection/collection.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'; - -import 'package:fluffychat/config/app_config.dart'; -import 'package:fluffychat/l10n/l10n.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/bot/utils/bot_name.dart'; -import 'package:fluffychat/pangea/chat/constants/default_power_level.dart'; -import 'package:fluffychat/pangea/chat_settings/constants/bot_mode.dart'; -import 'package:fluffychat/pangea/chat_settings/models/bot_options_model.dart'; -import 'package:fluffychat/pangea/common/utils/error_handler.dart'; -import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; -import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart'; -import 'package:fluffychat/widgets/avatar.dart'; -import 'package:fluffychat/widgets/matrix.dart'; -import 'package:fluffychat/widgets/mxc_image.dart'; - -class ActivityRoomSelection extends StatefulWidget { - final ActivityPlannerBuilderState controller; - final Widget backButton; - - const ActivityRoomSelection({ - super.key, - required this.controller, - required this.backButton, - }); - - @override - State createState() => ActivityRoomSelectionState(); -} - -class ActivityRoomSelectionState extends State { - final TextEditingController searchController = TextEditingController(); - final FocusNode searchFocusNode = FocusNode(); - - bool _loading = false; - bool _complete = false; - - bool _hasBotDM = true; - List _launchableRooms = []; - final List _selectedRooms = []; - - @override - void initState() { - super.initState(); - _launchableRooms = Matrix.of(context) - .client - .rooms - .where((room) { - return room.canSendDefaultStates && - !room.isSpace && - !room.isAnalyticsRoom; - }) - .toList() - .sorted((a, b) { - final aIsBotDM = a.directChatMatrixID == BotName.byEnvironment; - final bIsBotDM = b.directChatMatrixID == BotName.byEnvironment; - if (aIsBotDM && !bIsBotDM) return -1; - if (!aIsBotDM && bIsBotDM) return 1; - return a.name.toLowerCase().compareTo(b.name.toLowerCase()); - }); - - final room = widget.controller.room; - if (room != null && room.isSpace) { - _launchableRooms = _launchableRooms.where((r) { - return room.spaceChildren.any((child) => child.roomId == r.id); - }).toList(); - } - - _hasBotDM = Matrix.of(context).client.rooms.any((room) { - if (room.isDirectChat && - room.directChatMatrixID == BotName.byEnvironment) { - return true; - } - if (room.botOptions?.mode == BotMode.directChat) { - return true; - } - return false; - }); - } - - @override - void dispose() { - searchController.dispose(); - searchFocusNode.dispose(); - super.dispose(); - } - - List get _filteredRooms { - final searchText = searchController.text.toLowerCase(); - return _launchableRooms.where((room) { - return room.name.toLowerCase().contains(searchText); - }).toList(); - } - - void _toggleRoomSelection(String roomId) { - _selectedRooms.contains(roomId) - ? _selectedRooms.remove(roomId) - : _selectedRooms.add(roomId); - if (_selectedRooms.contains(roomId)) { - _complete = false; - } - - setState(() {}); - } - - Map get _spaceDelegateCandidates { - final spaces = Matrix.of(context).client.rooms.where((r) => r.isSpace); - final candidates = {}; - for (final space in spaces) { - for (final spaceChild in space.spaceChildren) { - final roomId = spaceChild.roomId; - if (roomId == null) continue; - candidates[roomId] = space; - } - } - return candidates; - } - - final Map _launchStatus = {}; - - Future _sendActivityPlan(Room room) async { - try { - setState(() => _launchStatus[room.id] = 0); - await room.sendActivityPlan( - widget.controller.updatedActivity, - avatar: widget.controller.avatar, - filename: widget.controller.filename, - ); - _launchStatus[room.id] = 1; - } catch (e, s) { - _launchStatus[room.id] = -1; - ErrorHandler.logError( - e: e, - s: s, - data: { - "roomID": room.id, - "activity": widget.controller.updatedActivity.toJson(), - "filename": widget.controller.filename, - "avatarURL": widget.controller.imageURL, - }, - ); - } finally { - if (mounted) { - setState(() {}); - } - } - } - - Future _launchBotDM() async { - try { - setState(() => _launchStatus["placeholder"] = 0); - - Uri? avatarUrl; - final imageUrl = widget.controller.imageURL ?? - widget.controller.updatedActivity.imageURL; - - Uint8List? avatar = widget.controller.avatar; - if (avatar != null) { - avatarUrl = await Matrix.of(context).client.uploadContent( - widget.controller.avatar!, - ); - } else if (imageUrl != null) { - final Response response = await http.get(Uri.parse(imageUrl)); - avatar = response.bodyBytes; - avatarUrl = await Matrix.of(context).client.uploadContent( - avatar, - ); - } - - // avatar == null ? null : await client.uploadContent(avatar); - final roomId = await Matrix.of(context).client.createRoom( - name: widget.controller.updatedActivity.title, - invite: [BotName.byEnvironment], - isDirect: true, - preset: CreateRoomPreset.trustedPrivateChat, - initialState: [ - BotOptionsModel(mode: BotMode.directChat).toStateEvent, - RoomDefaults.defaultPowerLevels( - Matrix.of(context).client.userID!, - ), - if (avatar != null && avatarUrl != null) - StateEvent( - type: EventTypes.RoomAvatar, - content: {'url': avatarUrl.toString()}, - ), - ], - ); - Room? room = Matrix.of(context).client.getRoomById(roomId); - if (room == null) { - await Matrix.of(context).client.waitForRoomInSync( - roomId, - join: true, - ); - - room = Matrix.of(context).client.getRoomById(roomId); - if (room == null) { - throw Exception("Room not found"); - } - - await room.sendActivityPlan( - widget.controller.updatedActivity, - avatar: widget.controller.avatar, - filename: widget.controller.filename, - ); - } - _launchStatus["placeholder"] = 1; - return roomId; - } catch (e, s) { - _launchStatus["placeholder"] = -1; - ErrorHandler.logError( - e: e, - s: s, - data: { - "activity": widget.controller.updatedActivity.toJson(), - "filename": widget.controller.filename, - "avatarURL": widget.controller.imageURL, - }, - ); - } finally { - if (mounted) { - setState(() {}); - } - } - return null; - } - - Future _launch() async { - setState(() => _loading = true); - try { - final List futures = []; - for (final roomId in _selectedRooms) { - if (_launchStatus[roomId] == 1) { - continue; - } - - final Room? room = _launchableRooms.firstWhereOrNull( - (r) => r.id == roomId, - ); - if (room == null) { - if (roomId == 'placeholder') futures.add(_launchBotDM()); - } else { - futures.add(_sendActivityPlan(room)); - } - } - - final resp = await Future.wait(futures); - _complete = true; - if (!mounted) return; - if (_selectedRooms.length == 1 && - _launchStatus[_selectedRooms.first] == 1) { - if (_selectedRooms.first == 'placeholder' && resp.first != null) { - context.go("/rooms/${resp.first}"); - Navigator.of(context).pop(); - } else if (_selectedRooms.first != 'placeholder') { - context.go('/rooms/${_selectedRooms.first}'); - Navigator.of(context).pop(); - } - } - } catch (e, s) { - ErrorHandler.logError( - e: e, - s: s, - data: { - "activity": widget.controller.updatedActivity.toJson(), - "filename": widget.controller.filename, - "avatarURL": widget.controller.imageURL, - }, - ); - } finally { - if (mounted) { - setState(() => _loading = false); - } - } - } - - String _tooltip(String roomId) { - final status = _launchStatus[roomId]; - if (status == 0) { - return "Sending..."; - } else if (status == 1) { - return "Go to chat"; - } else if (status == -1) { - return "Failed to send"; - } - return ""; - } - - void _onTap(Room room) { - final status = _launchStatus[room.id]; - if (status == 0) { - return; - } else if (status == 1) { - context.go('/rooms/${room.id}'); - Navigator.of(context).pop(); - } else if (status == -1) { - return; - } - - debugPrint("Toggling room selection for ${room.id}"); - _toggleRoomSelection(room.id); - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return Scaffold( - appBar: AppBar( - title: Text(L10n.of(context).selectChats), - leading: widget.backButton, - ), - body: Padding( - padding: const EdgeInsets.symmetric(vertical: 16.0), - child: Column( - spacing: 16.0, - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: TextField( - controller: searchController, - focusNode: searchFocusNode, - textInputAction: TextInputAction.search, - onChanged: (text) => setState(() {}), - decoration: InputDecoration( - filled: true, - fillColor: theme.colorScheme.secondaryContainer, - border: OutlineInputBorder( - borderSide: BorderSide.none, - borderRadius: BorderRadius.circular(99), - ), - hintText: L10n.of(context).searchChats, - hintStyle: TextStyle( - color: theme.colorScheme.onPrimaryContainer, - fontWeight: FontWeight.normal, - ), - floatingLabelBehavior: FloatingLabelBehavior.never, - suffixIcon: searchController.text.isNotEmpty - ? IconButton( - tooltip: L10n.of(context).cancel, - icon: const Icon(Icons.close_outlined), - onPressed: () { - setState(() { - searchController.clear(); - searchFocusNode.unfocus(); - }); - }, - color: theme.colorScheme.onPrimaryContainer, - ) - : IconButton( - onPressed: () => searchFocusNode.requestFocus(), - icon: Icon( - Icons.search_outlined, - color: theme.colorScheme.onPrimaryContainer, - ), - ), - ), - ), - ), - Expanded( - child: ListView.builder( - itemCount: _filteredRooms.length + (_hasBotDM ? 0 : 1), - itemBuilder: (context, index) { - if (!_hasBotDM && index == 0) { - return ChatActivityPlaceholder( - activity: widget.controller.updatedActivity, - selected: _selectedRooms.contains("placeholder"), - onTap: () { - _toggleRoomSelection("placeholder"); - }, - tooltip: _tooltip("placeholder"), - status: _launchStatus["placeholder"], - avatar: widget.controller.avatar, - ); - } - if (!_hasBotDM) index--; - - final room = _filteredRooms[index]; - final displayname = room.getLocalizedDisplayname( - MatrixLocals(L10n.of(context)), - ); - final space = _spaceDelegateCandidates[room.id]; - return Tooltip( - message: _tooltip(room.id), - child: ListTile( - title: Text(displayname), - leading: SizedBox( - width: Avatar.defaultSize, - height: Avatar.defaultSize, - child: Stack( - children: [ - if (space != null) - Positioned( - top: 0, - left: 0, - child: Avatar( - border: BorderSide( - width: 2, - color: theme.colorScheme.surface, - ), - borderRadius: BorderRadius.circular( - AppConfig.borderRadius / 4, - ), - mxContent: space.avatar, - size: Avatar.defaultSize * 0.75, - name: space.getLocalizedDisplayname(), - ), - ), - Positioned( - bottom: 0, - right: 0, - child: Avatar( - border: space == null - ? room.isSpace - ? BorderSide( - width: 1, - color: theme.dividerColor, - ) - : null - : BorderSide( - width: 2, - color: theme.colorScheme.surface, - ), - mxContent: room.avatar, - size: Avatar.defaultSize * 0.75, - name: displayname, - presenceUserId: room.directChatMatrixID, - ), - ), - ], - ), - ), - trailing: Container( - width: 30.0, - height: 30.0, - alignment: Alignment.center, - child: Builder( - builder: (context) { - final status = _launchStatus[room.id]; - - if (status == 0) { - return const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator.adaptive(), - ); - } else if (status == 1) { - return const Icon( - Icons.check_circle_outline, - color: AppConfig.success, - ); - } else if (status == -1) { - return Icon( - Icons.error_outline, - color: theme.colorScheme.error, - ); - } - - return Checkbox( - value: _selectedRooms.contains(room.id), - onChanged: (_) => _onTap(room), - ); - }, - ), - ), - onTap: () => _onTap(room), - ), - ); - }, - ), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: _complete - ? Padding( - padding: const EdgeInsets.all(8.0), - child: Text(L10n.of(context).selectChatToStart), - ) - : ElevatedButton( - onPressed: _selectedRooms.isNotEmpty ? _launch : null, - style: ElevatedButton.styleFrom( - minimumSize: Size.zero, - padding: const EdgeInsets.all(6.0), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12.0), - ), - backgroundColor: theme.colorScheme.primary, - foregroundColor: theme.colorScheme.onPrimary, - disabledBackgroundColor: theme.colorScheme.primary, - disabledForegroundColor: theme.colorScheme.onPrimary, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - _loading - ? const Expanded( - child: SizedBox( - height: 10, - child: LinearProgressIndicator(), - ), - ) - : Text( - L10n.of(context).launchActivityToChats, - style: theme.textTheme.bodyLarge?.copyWith( - color: theme.colorScheme.onPrimary, - ), - ), - ], - ), - ), - ), - ], - ), - ), - ); - } -} - -class ChatActivityPlaceholder extends StatelessWidget { - final ActivityPlanModel activity; - final bool selected; - final VoidCallback onTap; - final String tooltip; - final Uint8List? avatar; - final int? status; - - const ChatActivityPlaceholder({ - required this.activity, - required this.selected, - required this.onTap, - required this.tooltip, - required this.status, - this.avatar, - super.key, - }); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - const size = Avatar.defaultSize * 0.75; - return Tooltip( - message: tooltip, - child: ListTile( - title: Text(activity.title), - leading: SizedBox( - width: Avatar.defaultSize, - height: Avatar.defaultSize, - child: SizedBox( - width: size, - height: size, - child: Material( - color: theme.brightness == Brightness.light - ? Colors.white - : Colors.black, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(size / 2), - side: BorderSide.none, - ), - clipBehavior: Clip.hardEdge, - child: avatar != null - ? Image.memory(avatar!) - : activity.imageURL != null - ? activity.imageURL!.startsWith('mxc') - ? MxcImage( - uri: Uri.parse(activity.imageURL!), - width: size, - height: size, - cacheKey: activity.bookmarkId, - fit: BoxFit.cover, - ) - : CachedNetworkImage( - imageUrl: activity.imageURL!, - placeholder: (context, url) => const Center( - child: CircularProgressIndicator(), - ), - errorWidget: (context, url, error) => - const SizedBox(), - fit: BoxFit.cover, - ) - : const SizedBox(), - ), - ), - ), - trailing: Container( - width: 30.0, - height: 30.0, - alignment: Alignment.center, - child: Builder( - builder: (context) { - if (status == 0) { - return const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator.adaptive(), - ); - } else if (status == 1) { - return const Icon( - Icons.check_circle_outline, - color: AppConfig.success, - ); - } else if (status == -1) { - return Icon( - Icons.error_outline, - color: theme.colorScheme.error, - ); - } - - return Checkbox( - value: selected, - onChanged: (_) => onTap(), - ); - }, - ), - ), - onTap: onTap, - ), - ); - } -} diff --git a/lib/pangea/activity_suggestions/activity_suggestion_card_row.dart b/lib/pangea/activity_suggestions/activity_suggestion_card_row.dart index 4bca04a1c..5b7590031 100644 --- a/lib/pangea/activity_suggestions/activity_suggestion_card_row.dart +++ b/lib/pangea/activity_suggestions/activity_suggestion_card_row.dart @@ -1,12 +1,14 @@ import 'package:flutter/material.dart'; class ActivitySuggestionCardRow extends StatelessWidget { - final IconData icon; + final IconData? icon; + final Widget? leading; final Widget child; const ActivitySuggestionCardRow({ - required this.icon, required this.child, + this.icon, + this.leading, super.key, }); @@ -15,12 +17,14 @@ class ActivitySuggestionCardRow extends StatelessWidget { return Padding( padding: const EdgeInsets.symmetric(vertical: 4.0), child: Row( - spacing: 8.0, + spacing: 12.0, children: [ - Icon( - icon, - size: 16.0, - ), + if (leading != null) leading!, + if (icon != null) + Icon( + icon, + size: 24.0, + ), Expanded(child: child), ], ), diff --git a/lib/pangea/activity_suggestions/activity_suggestion_carousel.dart b/lib/pangea/activity_suggestions/activity_suggestion_carousel.dart deleted file mode 100644 index 360003f01..000000000 --- a/lib/pangea/activity_suggestions/activity_suggestion_carousel.dart +++ /dev/null @@ -1,353 +0,0 @@ -import 'dart:typed_data'; -import 'dart:ui'; - -import 'package:flutter/material.dart'; - -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:collection/collection.dart'; -import 'package:shimmer/shimmer.dart'; - -import 'package:fluffychat/config/themes.dart'; -import 'package:fluffychat/l10n/l10n.dart'; -import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart'; -import 'package:fluffychat/pangea/activity_planner/activity_plan_request.dart'; -import 'package:fluffychat/pangea/activity_planner/activity_planner_builder.dart'; -import 'package:fluffychat/pangea/activity_planner/media_enum.dart'; -import 'package:fluffychat/pangea/activity_suggestions/activity_plan_search_repo.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/learning_settings/constants/language_constants.dart'; -import 'package:fluffychat/pangea/learning_settings/enums/language_level_type_enum.dart'; -import 'package:fluffychat/widgets/matrix.dart'; - -class ActivitySuggestionCarousel extends StatefulWidget { - final Function( - ActivityPlanModel?, - Uint8List? avatar, - String? filename, - ) onActivitySelected; - final ActivityPlanModel? selectedActivity; - final Uint8List? selectedActivityImage; - final bool enabled; - - const ActivitySuggestionCarousel({ - required this.onActivitySelected, - required this.selectedActivity, - required this.selectedActivityImage, - this.enabled = true, - super.key, - }); - - @override - ActivitySuggestionCarouselState createState() => - ActivitySuggestionCarouselState(); -} - -class ActivitySuggestionCarouselState - extends State { - bool _loading = true; - bool _closed = false; - String? _error; - - double get _cardWidth => _isColumnMode ? 250.0 : 175.0; - double get _cardHeight => _isColumnMode ? 360.0 : 275.0; - - ActivityPlanModel? _currentActivity; - final List _activityItems = []; - - @override - void initState() { - super.initState(); - _setActivityItems(); - } - - @override - void didUpdateWidget(covariant ActivitySuggestionCarousel oldWidget) { - super.didUpdateWidget(oldWidget); - if (widget.selectedActivity != oldWidget.selectedActivity && - _currentIndex != null && - widget.selectedActivity != null) { - final prevIndex = _currentIndex!; - setState( - () { - _activityItems[prevIndex] = widget.selectedActivity!; - _currentActivity = widget.selectedActivity; - }, - ); - } - } - - Future _setActivityItems() async { - try { - final ActivityPlanRequest request = ActivityPlanRequest( - topic: "", - mode: "", - objective: "", - media: MediaEnum.nan, - cefrLevel: LanguageLevelTypeEnum.a1, - languageOfInstructions: LanguageKeys.defaultLanguage, - targetLanguage: - MatrixState.pangeaController.languageController.userL2?.langCode ?? - LanguageKeys.defaultLanguage, - numberOfParticipants: 3, - count: 5, - ); - final resp = await ActivitySearchRepo.get(request); - _activityItems.addAll(resp.activityPlans); - } catch (e) { - _error = e.toString(); - } finally { - _loading = false; - _currentActivity = - _activityItems.isNotEmpty ? _activityItems.first : null; - if (mounted) setState(() {}); - } - } - - bool get _isColumnMode => FluffyThemes.isColumnMode(context); - - int? get _currentIndex { - if (_currentActivity == null) return null; - final index = _activityItems.indexOf(_currentActivity!); - return index == -1 ? null : index; - } - - bool get _canMoveLeft => - widget.enabled && _currentIndex != null && _currentIndex! > 0; - - bool get _canMoveRight => - widget.enabled && - _currentIndex != null && - _currentIndex! < _activityItems.length - 1; - - void _moveLeft() { - if (!_canMoveLeft) return; - _setActivityByIndex(_currentIndex! - 1); - } - - void _moveRight() { - if (!_canMoveRight) return; - _setActivityByIndex(_currentIndex! + 1); - } - - void _setActivityByIndex(int index) { - if (index < 0 || index >= _activityItems.length) return; - setState(() { - _currentActivity = _activityItems[index]; - }); - } - - void _close() { - widget.onActivitySelected(null, null, null); - setState(() { - _closed = true; - }); - } - - void _onReplaceActivity(ActivityPlanModel a) { - final index = _currentIndex; - if (index == null || index < 0 || index >= _activityItems.length) { - return; - } - _activityItems[index] = a; - setState(() => _currentActivity = a); - } - - void _onClickCard() { - if (widget.selectedActivity == _currentActivity) { - widget.onActivitySelected( - null, - null, - null, - ); - return; - } - - showDialog( - context: context, - builder: (context) { - return ActivityPlannerBuilder( - initialActivity: _currentActivity!, - builder: (controller) { - return ActivitySuggestionDialog( - controller: controller, - buttonText: L10n.of(context).selectActivity, - replaceActivity: _onReplaceActivity, - onLaunch: () => widget.onActivitySelected( - controller.updatedActivity, - controller.avatar, - controller.filename, - ), - ); - }, - ); - }, - ); - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return AnimatedSize( - duration: FluffyThemes.animationDuration, - child: AnimatedOpacity( - duration: FluffyThemes.animationDuration, - opacity: widget.enabled ? 1.0 : 0.5, - child: _closed - ? const SizedBox.shrink() - : Container( - decoration: BoxDecoration( - border: Border.all(color: theme.dividerColor), - borderRadius: BorderRadius.circular(24.0), - ), - padding: const EdgeInsets.symmetric( - vertical: 16.0, - horizontal: 4.0, - ), - child: Column( - spacing: 16.0, - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - L10n.of(context).newChatActivityTitle, - style: theme.textTheme.titleLarge, - ), - IconButton( - icon: const Icon(Icons.close), - onPressed: widget.enabled ? _close : null, - ), - ], - ), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12.0), - child: Text(L10n.of(context).newChatActivityDesc), - ), - Row( - spacing: _isColumnMode ? 16.0 : 4.0, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - MouseRegion( - cursor: _canMoveLeft - ? SystemMouseCursors.click - : SystemMouseCursors.basic, - child: GestureDetector( - onTap: _canMoveLeft ? _moveLeft : null, - child: Icon( - Icons.chevron_left_outlined, - size: 32.0, - color: _canMoveLeft ? null : theme.disabledColor, - ), - ), - ), - Container( - constraints: - BoxConstraints(maxHeight: _cardHeight + 12.0), - child: _error != null || - (_currentActivity == null && !_loading) - ? const SizedBox.shrink() - : _loading - ? Shimmer.fromColors( - baseColor: theme.colorScheme.primary - .withAlpha(50), - highlightColor: theme.colorScheme.primary - .withAlpha(150), - child: Container( - height: _cardHeight, - width: _cardWidth, - decoration: BoxDecoration( - color: theme - .colorScheme.surfaceContainer, - borderRadius: - BorderRadius.circular(24.0), - ), - ), - ) - : ActivitySuggestionCard( - selected: widget.selectedActivity == - _currentActivity, - activity: _currentActivity!, - onPressed: - widget.enabled ? _onClickCard : null, - width: _cardWidth, - height: _cardHeight, - image: _currentActivity == - widget.selectedActivity - ? widget.selectedActivityImage - : null, - onChange: () { - if (mounted) setState(() {}); - }, - ), - ), - MouseRegion( - cursor: _canMoveRight - ? SystemMouseCursors.click - : SystemMouseCursors.basic, - child: GestureDetector( - onTap: _canMoveRight ? _moveRight : null, - child: Icon( - Icons.chevron_right_outlined, - size: 32.0, - color: _canMoveRight ? null : theme.disabledColor, - ), - ), - ), - ], - ), - Row( - mainAxisAlignment: MainAxisAlignment.center, - spacing: 16.0, - children: _activityItems.mapIndexed((i, activity) { - final selected = activity == _currentActivity; - return InkWell( - enableFeedback: widget.enabled, - borderRadius: BorderRadius.circular(12.0), - onTap: widget.enabled - ? () => _setActivityByIndex(i) - : null, - child: ImageFiltered( - imageFilter: ImageFilter.blur( - sigmaX: selected ? 0.0 : 0.5, - sigmaY: selected ? 0.0 : 0.5, - ), - child: Opacity( - opacity: selected ? 1.0 : 0.5, - child: ClipOval( - child: SizedBox.fromSize( - size: const Size.fromRadius(12.0), - child: activity.imageURL != null - ? CachedNetworkImage( - imageUrl: activity.imageURL!, - errorWidget: (context, url, error) => - const SizedBox(), - progressIndicatorBuilder: - (context, url, progress) { - return CircularProgressIndicator( - value: progress.progress, - ); - }, - ) - : CircleAvatar( - backgroundColor: - theme.colorScheme.secondary, - radius: 12.0, - ), - ), - ), - ), - ), - ); - }).toList(), - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/pangea/activity_suggestions/activity_suggestion_dialog.dart b/lib/pangea/activity_suggestions/activity_suggestion_dialog.dart index c917668ab..ad6e966e6 100644 --- a/lib/pangea/activity_suggestions/activity_suggestion_dialog.dart +++ b/lib/pangea/activity_suggestions/activity_suggestion_dialog.dart @@ -1,41 +1,27 @@ import 'package:flutter/material.dart'; -import 'package:cached_network_image/cached_network_image.dart'; import 'package:collection/collection.dart'; import 'package:go_router/go_router.dart'; -import 'package:material_symbols_icons/symbols.dart'; import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/pangea/activity_planner/activity_plan_generation_repo.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/activity_suggestions/activity_room_selection.dart'; -import 'package:fluffychat/pangea/activity_suggestions/activity_suggestion_card_row.dart'; -import 'package:fluffychat/pangea/chat_settings/widgets/language_level_dropdown.dart'; +import 'package:fluffychat/pangea/activity_suggestions/activity_suggestion_dialog_content.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/common/widgets/error_indicator.dart'; import 'package:fluffychat/pangea/common/widgets/full_width_dialog.dart'; -import 'package:fluffychat/pangea/learning_settings/enums/language_level_type_enum.dart'; -import 'package:fluffychat/widgets/future_loading_dialog.dart'; -import 'package:fluffychat/widgets/mxc_image.dart'; - -enum _PageMode { - activity, - roomSelection, -} class ActivitySuggestionDialog extends StatefulWidget { final ActivityPlannerBuilderState controller; final String buttonText; - final VoidCallback? onLaunch; final Function(ActivityPlanModel)? replaceActivity; const ActivitySuggestionDialog({ required this.controller, required this.buttonText, - this.onLaunch, this.replaceActivity, super.key, }); @@ -46,40 +32,33 @@ class ActivitySuggestionDialog extends StatefulWidget { } class ActivitySuggestionDialogState extends State { - _PageMode _pageMode = _PageMode.activity; - bool _loading = false; - Object? _error; + String? _regenerateError; + String? _launchError; double get _width => FluffyThemes.isColumnMode(context) ? 400.0 : MediaQuery.of(context).size.width; - Future _launchActivity() async { + Future launchActivity() async { try { + if (!widget.controller.room.isSpace) { + throw Exception( + "Cannot launch activity in a non-space room", + ); + } + setState(() { _loading = true; - _error = null; + _regenerateError = null; + _launchError = null; }); - if (widget.onLaunch != null) { - widget.onLaunch!.call(); - Navigator.of(context).pop(); - } else if (widget.controller.room != null && - !widget.controller.room!.isSpace) { - final resp = await showFutureLoadingDialog( - context: context, - future: widget.controller.launchToRoom, - ); - if (!resp.isError) { - context.go("/rooms/${widget.controller.room!.id}"); - Navigator.of(context).pop(); - } - } else { - _setPageMode(_PageMode.roomSelection); - } + await widget.controller.launchToSpace(); + context.go("/rooms?spaceId=${widget.controller.room.id}"); + Navigator.of(context).pop(); } catch (e, s) { - _error = e; + _launchError = L10n.of(context).errorLaunchActivityMessage; ErrorHandler.logError( e: e, s: s, @@ -96,16 +75,11 @@ class ActivitySuggestionDialogState extends State { } } - void _setPageMode(_PageMode mode) { - setState(() { - _pageMode = mode; - }); - } - - Future _onRegenerate() async { + Future onRegenerate() async { setState(() { _loading = true; - _error = null; + _regenerateError = null; + _launchError = null; }); try { @@ -121,7 +95,7 @@ class ActivitySuggestionDialogState extends State { widget.replaceActivity?.call(plan); await widget.controller.overrideActivity(plan); } catch (e, s) { - _error = e; + _regenerateError = L10n.of(context).errorRegenerateActivityMessage; ErrorHandler.logError( e: e, s: s, @@ -142,22 +116,28 @@ class ActivitySuggestionDialogState extends State { void _resetActivity() { widget.controller.resetActivity(); setState(() { - _pageMode = _PageMode.activity; _loading = false; - _error = null; + _regenerateError = null; + _launchError = null; }); } + ButtonStyle get buttonStyle => ElevatedButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.primaryContainer, + foregroundColor: Theme.of(context).colorScheme.onPrimaryContainer, + padding: const EdgeInsets.symmetric( + horizontal: 12.0, + ), + ); + + double get width => FluffyThemes.isColumnMode(context) + ? 400.0 + : MediaQuery.of(context).size.width; + @override Widget build(BuildContext context) { final theme = Theme.of(context); - final buttonStyle = ElevatedButton.styleFrom( - backgroundColor: theme.colorScheme.primaryContainer, - foregroundColor: theme.colorScheme.onPrimaryContainer, - padding: const EdgeInsets.symmetric( - horizontal: 12.0, - ), - ); + final buttonStyle = this.buttonStyle; final body = Stack( alignment: Alignment.topCenter, @@ -168,23 +148,22 @@ class ActivitySuggestionDialogState extends State { ), child: Builder( builder: (context) { - if (_pageMode == _PageMode.activity) { - if (_error != null) { - return Center( - child: Column( - spacing: 16.0, - mainAxisSize: MainAxisSize.min, - children: [ - ErrorIndicator( - message: - L10n.of(context).errorRegenerateActivityMessage, - ), + if (_regenerateError != null || _launchError != null) { + return Center( + child: Column( + spacing: 16.0, + mainAxisSize: MainAxisSize.min, + children: [ + ErrorIndicator( + message: _regenerateError ?? _launchError!, + ), + if (_regenerateError != null) Row( spacing: 8.0, mainAxisSize: MainAxisSize.min, children: [ ElevatedButton( - onPressed: _onRegenerate, + onPressed: onRegenerate, style: buttonStyle, child: Text(L10n.of(context).tryAgain), ), @@ -194,552 +173,46 @@ class ActivitySuggestionDialogState extends State { child: Text(L10n.of(context).reset), ), ], + ) + else + ElevatedButton( + onPressed: launchActivity, + style: buttonStyle, + child: Text(L10n.of(context).tryAgain), ), - ], - ), - ); - } - - if (_loading) { - return const Center( - child: CircularProgressIndicator.adaptive(), - ); - } - - return Form( - key: widget.controller.formKey, - child: Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Flexible( - child: SingleChildScrollView( - child: Column( - spacing: 8.0, - mainAxisSize: MainAxisSize.min, - children: [ - Stack( - alignment: Alignment.bottomCenter, - children: [ - Container( - padding: const EdgeInsets.all(24.0), - width: (_width / 2) + 42.0, - child: ClipRRect( - borderRadius: BorderRadius.circular(20.0), - child: widget.controller.avatar != null - ? Image.memory( - widget.controller.avatar!, - fit: BoxFit.cover, - ) - : widget.controller.updatedActivity - .imageURL != - null - ? widget.controller - .updatedActivity.imageURL! - .startsWith("mxc") - ? MxcImage( - uri: Uri.parse( - widget - .controller - .updatedActivity - .imageURL!, - ), - width: _width / 2, - height: 200, - cacheKey: widget - .controller - .updatedActivity - .bookmarkId, - fit: BoxFit.cover, - ) - : CachedNetworkImage( - imageUrl: widget - .controller - .updatedActivity - .imageURL!, - fit: BoxFit.cover, - placeholder: ( - context, - url, - ) => - const Center( - child: - CircularProgressIndicator(), - ), - errorWidget: ( - context, - url, - error, - ) => - const SizedBox(), - ) - : null, - ), - ), - if (widget.controller.isEditing) - InkWell( - borderRadius: BorderRadius.circular(90), - onTap: widget.controller.selectAvatar, - child: CircleAvatar( - backgroundColor: Theme.of(context) - .colorScheme - .secondary, - radius: 20.0, - child: Icon( - Icons.add_a_photo_outlined, - size: 20.0, - color: Theme.of(context) - .colorScheme - .onSecondary, - ), - ), - ), - ], - ), - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16.0, - vertical: 8.0, - ), - child: Column( - children: [ - if (widget.controller.isEditing) - ActivitySuggestionCardRow( - icon: Icons.event_note_outlined, - child: TextFormField( - controller: - widget.controller.titleController, - decoration: InputDecoration( - labelText: - L10n.of(context).activityTitle, - ), - maxLines: 2, - minLines: 1, - ), - ) - else - ActivitySuggestionCardRow( - icon: Icons.event_note_outlined, - child: Text( - widget - .controller.updatedActivity.title, - style: theme.textTheme.titleLarge - ?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - ), - if (widget.controller.isEditing) - ActivitySuggestionCardRow( - icon: Symbols.target, - child: TextFormField( - controller: widget.controller - .learningObjectivesController, - decoration: InputDecoration( - labelText: L10n.of(context) - .learningObjectiveLabel, - ), - maxLines: 4, - minLines: 1, - ), - ) - else - ActivitySuggestionCardRow( - icon: Symbols.target, - child: Text( - widget.controller.updatedActivity - .learningObjective, - style: theme.textTheme.bodyLarge, - ), - ), - if (widget.controller.isEditing) - ActivitySuggestionCardRow( - icon: Symbols.steps, - child: TextFormField( - controller: widget.controller - .instructionsController, - decoration: InputDecoration( - labelText: - L10n.of(context).instructions, - ), - maxLines: 8, - minLines: 1, - ), - ) - else - ActivitySuggestionCardRow( - icon: Symbols.steps, - child: Text( - widget.controller.updatedActivity - .instructions, - style: theme.textTheme.bodyLarge, - ), - ), - if (widget.controller.isEditing) - ActivitySuggestionCardRow( - icon: Icons.group_outlined, - child: TextFormField( - controller: widget.controller - .participantsController, - decoration: InputDecoration( - labelText: - L10n.of(context).classRoster, - ), - maxLines: 1, - keyboardType: TextInputType.number, - validator: (value) { - if (value == null || - value.isEmpty) { - return null; - } - - try { - final val = int.parse(value); - if (val <= 0) { - return L10n.of(context) - .pleaseEnterInt; - } - if (val > 50) { - return L10n.of(context) - .maxFifty; - } - } catch (e) { - return L10n.of(context) - .pleaseEnterANumber; - } - return null; - }, - ), - ) - else - ActivitySuggestionCardRow( - icon: Icons.group_outlined, - child: Text( - L10n.of(context).countParticipants( - widget.controller.updatedActivity - .req.numberOfParticipants, - ), - style: theme.textTheme.bodyLarge, - ), - ), - if (widget.controller.isEditing) - ActivitySuggestionCardRow( - icon: Icons.school_outlined, - child: LanguageLevelDropdown( - initialLevel: - widget.controller.languageLevel, - onChanged: widget - .controller.setLanguageLevel, - ), - ) - else - ActivitySuggestionCardRow( - icon: Icons.school_outlined, - child: Text( - widget.controller.updatedActivity.req - .cefrLevel - .title(context), - style: theme.textTheme.bodyLarge, - ), - ), - if (widget.controller.isEditing) - ActivitySuggestionCardRow( - icon: Symbols.dictionary, - child: ConstrainedBox( - constraints: const BoxConstraints( - maxHeight: 60.0, - ), - child: SingleChildScrollView( - child: Wrap( - spacing: 4.0, - runSpacing: 4.0, - children: widget.controller.vocab - .mapIndexed( - (i, vocab) => Container( - padding: const EdgeInsets - .symmetric( - vertical: 4.0, - horizontal: 8.0, - ), - decoration: BoxDecoration( - color: theme - .colorScheme.primary - .withAlpha( - 20, - ), - borderRadius: - BorderRadius - .circular( - 24.0, - ), - ), - child: MouseRegion( - cursor: - SystemMouseCursors - .click, - child: GestureDetector( - onTap: () => widget - .controller - .removeVocab( - i, - ), - child: Row( - spacing: 4.0, - mainAxisSize: - MainAxisSize - .min, - children: [ - Text( - vocab.lemma, - ), - const Icon( - Icons.close, - size: 12.0, - ), - ], - ), - ), - ), - ), - ) - .toList(), - ), - ), - ), - ) - else - ActivitySuggestionCardRow( - icon: Symbols.dictionary, - child: ConstrainedBox( - constraints: const BoxConstraints( - maxHeight: 60.0, - ), - child: SingleChildScrollView( - child: Wrap( - spacing: 4.0, - runSpacing: 4.0, - children: widget.controller.vocab - .map( - (vocab) => Container( - padding: const EdgeInsets - .symmetric( - vertical: 4.0, - horizontal: 8.0, - ), - decoration: BoxDecoration( - color: theme - .colorScheme.primary - .withAlpha( - 20, - ), - borderRadius: - BorderRadius - .circular( - 24.0, - ), - ), - child: Text( - vocab.lemma, - style: theme.textTheme - .bodyMedium, - ), - ), - ) - .toList(), - ), - ), - ), - ), - if (widget.controller.isEditing) - Padding( - padding: const EdgeInsets.symmetric( - vertical: 4.0, - ), - child: Row( - spacing: 4.0, - children: [ - Expanded( - child: TextFormField( - controller: widget - .controller.vocabController, - decoration: InputDecoration( - hintText: L10n.of( - context, - ).addVocabulary, - ), - maxLines: 1, - onFieldSubmitted: (_) => widget - .controller - .addVocab(), - ), - ), - IconButton( - padding: const EdgeInsets.all( - 0.0, - ), - constraints: - const BoxConstraints(), // override default min size of 48px - iconSize: 16.0, - icon: const Icon( - Icons.add_outlined, - ), - onPressed: - widget.controller.addVocab, - ), - ], - ), - ), - ], - ), - ), - ], - ), - ), - ), - Padding( - padding: const EdgeInsets.all(16.0), - child: widget.controller.isEditing - ? Row( - spacing: 12.0, - children: [ - Expanded( - child: ElevatedButton( - style: buttonStyle, - onPressed: widget.controller.saveEdits, - child: Row( - children: [ - const Icon(Icons.save), - Expanded( - child: Text( - L10n.of(context).save, - textAlign: TextAlign.center, - ), - ), - ], - ), - ), - ), - Expanded( - child: ElevatedButton( - style: buttonStyle, - onPressed: widget.controller.clearEdits, - child: Row( - children: [ - const Icon(Icons.cancel), - Expanded( - child: Text( - L10n.of(context).cancel, - textAlign: TextAlign.center, - ), - ), - ], - ), - ), - ), - ], - ) - : Column( - spacing: 12.0, - children: [ - Row( - spacing: 12.0, - children: [ - Expanded( - child: ElevatedButton( - style: buttonStyle, - child: Row( - children: [ - const Icon(Icons.edit), - Expanded( - child: Text( - L10n.of(context).edit, - textAlign: TextAlign.center, - ), - ), - ], - ), - onPressed: () => widget.controller - .setEditing(true), - ), - ), - if (widget.replaceActivity != null) - Expanded( - child: ElevatedButton( - style: buttonStyle, - onPressed: _onRegenerate, - child: Row( - children: [ - const Icon( - Icons.lightbulb_outline, - ), - Expanded( - child: Text( - L10n.of(context).regenerate, - textAlign: TextAlign.center, - ), - ), - ], - ), - ), - ), - ], - ), - Row( - children: [ - Expanded( - child: ElevatedButton( - style: buttonStyle, - onPressed: _launchActivity, - child: Row( - children: [ - const Icon(Icons.send), - Expanded( - child: Text( - widget.buttonText, - textAlign: TextAlign.center, - ), - ), - ], - ), - ), - ), - ], - ), - ], - ), - ), ], ), ); } - return ActivityRoomSelection( - controller: widget.controller, - backButton: BackButton( - onPressed: () => _setPageMode( - _PageMode.activity, - ), - ), + if (_loading) { + return const Center( + child: CircularProgressIndicator.adaptive(), + ); + } + + return Form( + key: widget.controller.formKey, + child: ActivitySuggestionDialogContent(controller: this), ); }, ), ), - if (_pageMode == _PageMode.activity) - Positioned( - top: 4.0, - left: 4.0, - child: IconButton.filled( - style: IconButton.styleFrom( - backgroundColor: theme.colorScheme.surface.withAlpha(170), - ), - icon: Icon( - Icons.close_outlined, - color: theme.colorScheme.onSurface, - ), - onPressed: Navigator.of(context).pop, - tooltip: L10n.of(context).close, + Positioned( + top: 4.0, + left: 4.0, + child: IconButton.filled( + style: IconButton.styleFrom( + backgroundColor: theme.colorScheme.surface.withAlpha(170), ), + icon: Icon( + Icons.close_outlined, + color: theme.colorScheme.onSurface, + ), + onPressed: Navigator.of(context).pop, + tooltip: L10n.of(context).close, ), + ), ], ); @@ -750,3 +223,69 @@ class ActivitySuggestionDialogState extends State { ); } } + +class NumberCounter extends StatelessWidget { + final int count; + final Function(int) update; + + final int? min; + final int? max; + + const NumberCounter({ + super.key, + required this.count, + required this.update, + this.min, + this.max, + }); + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12.0), + color: Theme.of(context).colorScheme.surfaceContainerHighest, + ), + child: Row( + spacing: 4.0, + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.remove), + iconSize: 24.0, + style: IconButton.styleFrom( + padding: const EdgeInsets.all(0.0), + ), + onPressed: min == null || count - 1 >= min! + ? () { + if (count > 0) { + update(count - 1); + } + } + : null, + ), + Text( + count.toString(), + style: const TextStyle( + fontSize: 16.0, + ), + ), + IconButton( + icon: const Icon(Icons.add), + iconSize: 24.0, + style: max == null || count + 1 <= max! + ? IconButton.styleFrom( + padding: const EdgeInsets.all(0.0), + ) + : null, + onPressed: max == null || count + 1 <= max! + ? () { + update(count + 1); + } + : null, + ), + ], + ), + ); + } +} diff --git a/lib/pangea/activity_suggestions/activity_suggestion_dialog_content.dart b/lib/pangea/activity_suggestions/activity_suggestion_dialog_content.dart new file mode 100644 index 000000000..9e8eba047 --- /dev/null +++ b/lib/pangea/activity_suggestions/activity_suggestion_dialog_content.dart @@ -0,0 +1,712 @@ +import 'package:flutter/material.dart'; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:collection/collection.dart'; +import 'package:material_symbols_icons/symbols.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_row.dart'; +import 'package:fluffychat/pangea/activity_suggestions/activity_suggestion_dialog.dart'; +import 'package:fluffychat/pangea/chat_settings/widgets/language_level_dropdown.dart'; +import 'package:fluffychat/pangea/learning_settings/enums/language_level_type_enum.dart'; +import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart'; +import 'package:fluffychat/widgets/avatar.dart'; +import 'package:fluffychat/widgets/mxc_image.dart'; + +class ActivitySuggestionDialogContent extends StatelessWidget { + final ActivitySuggestionDialogState controller; + + const ActivitySuggestionDialogContent({ + super.key, + required this.controller, + }); + + @override + Widget build(BuildContext context) { + switch (controller.widget.controller.launchState) { + case ActivityLaunchState.base: + return _ActivitySuggestionBaseContent(controller: controller); + case ActivityLaunchState.editing: + return _ActivitySuggestionEditContent(controller: controller); + case ActivityLaunchState.launching: + return _ActivitySuggestionLaunchContent(controller: controller); + } + } +} + +class _ActivitySuggestionDialogImage extends StatelessWidget { + final ActivityPlannerBuilderState activityController; + final double width; + + const _ActivitySuggestionDialogImage({ + required this.activityController, + required this.width, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(24.0), + width: (width / 2) + 42.0, + child: ClipRRect( + borderRadius: BorderRadius.circular(20.0), + child: activityController.avatar != null + ? Image.memory( + activityController.avatar!, + fit: BoxFit.cover, + ) + : activityController.updatedActivity.imageURL != null + ? activityController.updatedActivity.imageURL!.startsWith("mxc") + ? MxcImage( + uri: Uri.parse( + activityController.updatedActivity.imageURL!, + ), + width: width / 2, + height: 200, + cacheKey: activityController.updatedActivity.bookmarkId, + fit: BoxFit.cover, + ) + : CachedNetworkImage( + imageUrl: activityController.updatedActivity.imageURL!, + fit: BoxFit.cover, + placeholder: ( + context, + url, + ) => + const Center( + child: CircularProgressIndicator(), + ), + errorWidget: ( + context, + url, + error, + ) => + const SizedBox(), + ) + : null, + ), + ); + } +} + +class _ActivitySuggestionDialogFrame extends StatelessWidget { + final Widget topContent; + final List centerContent; + final Widget bottomContent; + + const _ActivitySuggestionDialogFrame({ + required this.topContent, + required this.centerContent, + required this.bottomContent, + }); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: SingleChildScrollView( + child: Column( + spacing: 8.0, + mainAxisSize: MainAxisSize.min, + children: [ + topContent, + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 8.0, + ), + child: Column( + spacing: 14.0, + children: centerContent, + ), + ), + ], + ), + ), + ), + Padding( + padding: const EdgeInsets.all(16.0), + child: bottomContent, + ), + ], + ); + } +} + +class _ActivitySuggestionBaseContent extends StatelessWidget { + final ActivitySuggestionDialogState controller; + + const _ActivitySuggestionBaseContent({ + required this.controller, + }); + + ActivityPlannerBuilderState get activityController => + controller.widget.controller; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + + final topContent = _ActivitySuggestionDialogImage( + activityController: activityController, + width: controller.width, + ); + + final centerContent = [ + ActivitySuggestionCardRow( + icon: Icons.event_note_outlined, + child: Text( + activityController.updatedActivity.title, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + ), + ActivitySuggestionCardRow( + icon: Symbols.target, + child: Text( + activityController.updatedActivity.learningObjective, + style: const TextStyle(fontSize: 16), + ), + ), + ActivitySuggestionCardRow( + icon: Symbols.steps, + child: Text( + activityController.updatedActivity.instructions, + style: const TextStyle(fontSize: 16), + ), + ), + ActivitySuggestionCardRow( + icon: Icons.group_outlined, + child: Text( + L10n.of(context).countParticipants( + activityController.updatedActivity.req.numberOfParticipants, + ), + style: const TextStyle(fontSize: 16), + ), + ), + ActivitySuggestionCardRow( + icon: Icons.school_outlined, + child: Text( + activityController.updatedActivity.req.cefrLevel.title(context), + style: const TextStyle(fontSize: 16), + ), + ), + ActivitySuggestionCardRow( + icon: Symbols.dictionary, + child: ConstrainedBox( + constraints: const BoxConstraints( + maxHeight: 60.0, + ), + child: SingleChildScrollView( + child: Wrap( + spacing: 4.0, + runSpacing: 4.0, + children: activityController.vocab + .map( + (vocab) => Container( + padding: const EdgeInsets.symmetric( + vertical: 4.0, + horizontal: 8.0, + ), + decoration: BoxDecoration( + color: theme.colorScheme.primary.withAlpha( + 20, + ), + borderRadius: BorderRadius.circular( + 24.0, + ), + ), + child: Text( + vocab.lemma, + style: const TextStyle(fontSize: 12), + ), + ), + ) + .toList(), + ), + ), + ), + ), + ]; + + final bottomContent = Column( + spacing: 12.0, + children: [ + 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 (controller.widget.replaceActivity != null) + Expanded( + child: ElevatedButton( + style: controller.buttonStyle, + onPressed: controller.onRegenerate, + child: Row( + children: [ + const Icon( + Icons.lightbulb_outline, + ), + Expanded( + child: Text( + L10n.of(context).regenerate, + textAlign: TextAlign.center, + ), + ), + ], + ), + ), + ), + ], + ), + Row( + children: [ + Expanded( + child: ElevatedButton( + style: controller.buttonStyle, + // onPressed: _launchActivity, + onPressed: () { + activityController.setLaunchState( + ActivityLaunchState.launching, + ); + }, + child: Row( + spacing: 12.0, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.save_outlined), + Text( + controller.widget.buttonText, + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ], + ), + ], + ); + + return _ActivitySuggestionDialogFrame( + topContent: topContent, + centerContent: centerContent, + bottomContent: bottomContent, + ); + } +} + +class _ActivitySuggestionEditContent extends StatelessWidget { + final ActivitySuggestionDialogState controller; + + const _ActivitySuggestionEditContent({ + required this.controller, + }); + + ActivityPlannerBuilderState get activityController => + controller.widget.controller; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + + final topContent = Stack( + alignment: Alignment.bottomCenter, + children: [ + _ActivitySuggestionDialogImage( + activityController: activityController, + width: controller.width, + ), + InkWell( + borderRadius: BorderRadius.circular(90), + onTap: activityController.selectAvatar, + child: CircleAvatar( + backgroundColor: Theme.of(context).colorScheme.secondary, + radius: 20.0, + child: Icon( + Icons.add_a_photo_outlined, + size: 20.0, + color: Theme.of(context).colorScheme.onSecondary, + ), + ), + ), + ], + ); + + final centerContent = [ + ActivitySuggestionCardRow( + icon: Icons.event_note_outlined, + child: TextFormField( + controller: activityController.titleController, + decoration: InputDecoration( + labelText: L10n.of(context).activityTitle, + ), + maxLines: 2, + minLines: 1, + ), + ), + ActivitySuggestionCardRow( + icon: Symbols.target, + child: TextFormField( + controller: activityController.learningObjectivesController, + decoration: InputDecoration( + labelText: L10n.of(context).learningObjectiveLabel, + ), + maxLines: 4, + minLines: 1, + ), + ), + ActivitySuggestionCardRow( + icon: Symbols.steps, + child: TextFormField( + controller: activityController.instructionsController, + decoration: InputDecoration( + labelText: L10n.of(context).instructions, + ), + maxLines: 8, + minLines: 1, + ), + ), + ActivitySuggestionCardRow( + icon: Icons.group_outlined, + child: TextFormField( + controller: activityController.participantsController, + decoration: InputDecoration( + labelText: L10n.of(context).classRoster, + ), + maxLines: 1, + keyboardType: TextInputType.number, + validator: (value) { + if (value == null || value.isEmpty) { + return null; + } + + try { + final val = int.parse(value); + if (val <= 0) { + return L10n.of(context).pleaseEnterInt; + } + if (val > 50) { + return L10n.of(context).maxFifty; + } + } catch (e) { + return L10n.of(context).pleaseEnterANumber; + } + return null; + }, + ), + ), + ActivitySuggestionCardRow( + icon: Icons.school_outlined, + child: LanguageLevelDropdown( + initialLevel: activityController.languageLevel, + onChanged: activityController.setLanguageLevel, + ), + ), + ActivitySuggestionCardRow( + icon: Symbols.dictionary, + child: ConstrainedBox( + constraints: const BoxConstraints( + maxHeight: 60.0, + ), + child: SingleChildScrollView( + child: Wrap( + spacing: 4.0, + runSpacing: 4.0, + children: activityController.vocab + .mapIndexed( + (i, vocab) => Container( + padding: const EdgeInsets.symmetric( + vertical: 4.0, + horizontal: 8.0, + ), + decoration: BoxDecoration( + color: theme.colorScheme.primary.withAlpha( + 20, + ), + borderRadius: BorderRadius.circular( + 24.0, + ), + ), + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () => activityController.removeVocab( + i, + ), + child: Row( + spacing: 4.0, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + vocab.lemma, + ), + const Icon( + Icons.close, + size: 12.0, + ), + ], + ), + ), + ), + ), + ) + .toList(), + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.symmetric( + vertical: 4.0, + ), + child: Row( + spacing: 4.0, + children: [ + Expanded( + child: TextFormField( + controller: activityController.vocabController, + decoration: InputDecoration( + hintText: L10n.of( + context, + ).addVocabulary, + ), + maxLines: 1, + onFieldSubmitted: (_) => activityController.addVocab(), + ), + ), + IconButton( + padding: const EdgeInsets.all( + 0.0, + ), + constraints: + const BoxConstraints(), // override default min size of 48px + iconSize: 16.0, + icon: const Icon( + Icons.add_outlined, + ), + onPressed: activityController.addVocab, + ), + ], + ), + ), + ]; + + final bottomContent = Row( + spacing: 12.0, + children: [ + Expanded( + child: ElevatedButton( + style: controller.buttonStyle, + onPressed: activityController.saveEdits, + child: Row( + children: [ + const Icon(Icons.save), + Expanded( + child: Text( + L10n.of(context).save, + textAlign: TextAlign.center, + ), + ), + ], + ), + ), + ), + Expanded( + child: ElevatedButton( + style: controller.buttonStyle, + onPressed: activityController.clearEdits, + child: Row( + children: [ + const Icon(Icons.cancel), + Expanded( + child: Text( + L10n.of(context).cancel, + textAlign: TextAlign.center, + ), + ), + ], + ), + ), + ), + ], + ); + + return _ActivitySuggestionDialogFrame( + topContent: topContent, + centerContent: centerContent, + bottomContent: bottomContent, + ); + } +} + +class _ActivitySuggestionLaunchContent extends StatelessWidget { + final ActivitySuggestionDialogState controller; + + const _ActivitySuggestionLaunchContent({ + required this.controller, + }); + + ActivityPlannerBuilderState get activityController => + controller.widget.controller; + + @override + Widget build(BuildContext context) { + final topContent = Padding( + padding: const EdgeInsets.all(24.0), + child: Avatar( + mxContent: activityController.room.avatar, + name: activityController.room.getLocalizedDisplayname( + MatrixLocals( + L10n.of(context), + ), + ), + size: (controller.width / 2), + borderRadius: BorderRadius.circular(20.0), + ), + ); + + final centerContent = [ + ActivitySuggestionCardRow( + leading: Avatar( + mxContent: activityController.room.avatar, + name: activityController.room.getLocalizedDisplayname( + MatrixLocals( + L10n.of(context), + ), + ), + size: 24.0, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + activityController.room.getLocalizedDisplayname( + MatrixLocals(L10n.of(context)), + ), + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + ), + ActivitySuggestionCardRow( + leading: activityController.updatedActivity.imageURL != null + ? ClipRRect( + borderRadius: BorderRadius.circular(4.0), + child: activityController.updatedActivity.imageURL! + .startsWith("mxc") + ? MxcImage( + uri: Uri.parse( + activityController.updatedActivity.imageURL!, + ), + width: 24.0, + height: 24.0, + cacheKey: activityController.updatedActivity.bookmarkId, + fit: BoxFit.cover, + ) + : CachedNetworkImage( + imageUrl: activityController.updatedActivity.imageURL!, + fit: BoxFit.cover, + width: 24.0, + height: 24.0, + placeholder: ( + context, + url, + ) => + const Center( + child: CircularProgressIndicator(), + ), + errorWidget: ( + context, + url, + error, + ) => + const SizedBox(), + ), + ) + : const Icon( + Icons.event_note_outlined, + size: 24.0, + ), + child: Text( + activityController.updatedActivity.title, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + ), + ActivitySuggestionCardRow( + icon: Icons.groups, + child: Text( + L10n.of(context).minimumActivityParticipants( + activityController.updatedActivity.req.numberOfParticipants, + ), + style: const TextStyle(fontSize: 16), + ), + ), + ActivitySuggestionCardRow( + icon: Icons.radar, + child: Column( + spacing: 4.0, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + L10n.of(context).numberOfActivities, + style: const TextStyle(fontSize: 16), + ), + NumberCounter( + count: activityController.numActivities, + update: activityController.setNumActivities, + min: 1, + max: 5, + ), + ], + ), + ), + ]; + + final bottomContent = ElevatedButton( + style: controller.buttonStyle, + onPressed: controller.launchActivity, + child: Row( + spacing: 12.0, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.send_outlined), + Text( + L10n.of(context).launchToSpace, + textAlign: TextAlign.center, + ), + ], + ), + ); + + return _ActivitySuggestionDialogFrame( + topContent: topContent, + centerContent: centerContent, + bottomContent: bottomContent, + ); + } +} diff --git a/lib/pangea/activity_suggestions/activity_suggestions_area.dart b/lib/pangea/activity_suggestions/activity_suggestions_area.dart index 91df3e437..01983e281 100644 --- a/lib/pangea/activity_suggestions/activity_suggestions_area.dart +++ b/lib/pangea/activity_suggestions/activity_suggestions_area.dart @@ -26,12 +26,12 @@ import 'package:fluffychat/widgets/matrix.dart'; class ActivitySuggestionsArea extends StatefulWidget { final Axis? scrollDirection; - final Room? room; + final Room room; const ActivitySuggestionsArea({ super.key, this.scrollDirection, - this.room, + required this.room, }); @override ActivitySuggestionsAreaState createState() => ActivitySuggestionsAreaState(); @@ -173,7 +173,7 @@ class ActivitySuggestionsAreaState extends State { builder: (controller) { return ActivitySuggestionDialog( controller: controller, - buttonText: L10n.of(context).launchActivityButton, + buttonText: L10n.of(context).saveAndLaunch, replaceActivity: (a) => _onReplaceActivity(index, a), ); diff --git a/lib/pangea/activity_suggestions/activity_suggestions_constants.dart b/lib/pangea/activity_suggestions/activity_suggestions_constants.dart index ac1c16209..8d097a411 100644 --- a/lib/pangea/activity_suggestions/activity_suggestions_constants.dart +++ b/lib/pangea/activity_suggestions/activity_suggestions_constants.dart @@ -3,4 +3,5 @@ class ActivitySuggestionsConstants { static const String crayonIconPath = "make_your_own_icon.svg"; static const String modeImageFileStart = "activityplanner_mode_"; static const String makeActivityAssetPath = "Spark+imaginative.png"; + static const String endActivityAssetPath = "EndActivityMsg.png"; } diff --git a/lib/pangea/activity_summary/activity_summary_repo.dart b/lib/pangea/activity_summary/activity_summary_repo.dart new file mode 100644 index 000000000..f49014e27 --- /dev/null +++ b/lib/pangea/activity_summary/activity_summary_repo.dart @@ -0,0 +1,55 @@ +import 'dart:convert'; + +import 'package:get_storage/get_storage.dart'; +import 'package:http/http.dart'; + +import 'package:fluffychat/pangea/activity_summary/activity_summary_request_model.dart'; +import 'package:fluffychat/pangea/activity_summary/activity_summary_response_model.dart'; +import 'package:fluffychat/pangea/common/config/environment.dart'; +import 'package:fluffychat/pangea/common/network/requests.dart'; +import 'package:fluffychat/pangea/common/network/urls.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +class ActivitySummaryRepo { + static final GetStorage _activitySummaryStorage = + GetStorage('activity_summary_storage'); + + static void set( + ActivitySummaryRequestModel request, + ActivitySummaryResponseModel response, + ) { + _activitySummaryStorage.write(_storageKey(request), response.toJson()); + } + + static String _storageKey(ActivitySummaryRequestModel request) { + // You may want to customize this key based on request fields + return request.activity.hashCode.toString(); + } + + static Future get( + ActivitySummaryRequestModel request, + ) async { + final cachedJson = _activitySummaryStorage.read(_storageKey(request)); + if (cachedJson != null) { + final cached = ActivitySummaryResponseModel.fromJson(cachedJson); + return cached; + } + + final Requests req = Requests( + choreoApiKey: Environment.choreoApiKey, + accessToken: MatrixState.pangeaController.userController.accessToken, + ); + + final Response res = await req.post( + url: PApiUrls.activitySummary, + body: request.toJson(), + ); + + final decodedBody = jsonDecode(utf8.decode(res.bodyBytes)); + final response = ActivitySummaryResponseModel.fromJson(decodedBody); + + set(request, response); + + return response; + } +} diff --git a/lib/pangea/activity_summary/activity_summary_request_model.dart b/lib/pangea/activity_summary/activity_summary_request_model.dart new file mode 100644 index 000000000..4a86254a5 --- /dev/null +++ b/lib/pangea/activity_summary/activity_summary_request_model.dart @@ -0,0 +1,96 @@ +// Add this import for the participant summary model + +import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart'; +import 'package:fluffychat/pangea/activity_summary/activity_summary_response_model.dart'; + +class ActivitySummaryResultsMessage { + final String userId; + final String sent; + final String? written; + final List tool; + final DateTime time; + + ActivitySummaryResultsMessage({ + required this.userId, + required this.sent, + this.written, + required this.tool, + required this.time, + }); + + factory ActivitySummaryResultsMessage.fromJson(Map json) { + return ActivitySummaryResultsMessage( + userId: json['user_id'] as String, + sent: json['sent'] as String, + written: json['written'] as String?, + tool: (json['tool'] as List).map((e) => e as String).toList(), + time: DateTime.parse(json['time'] as String), + ); + } + + Map toJson() { + return { + 'user_id': userId, + 'sent': sent, + if (written != null) 'written': written, + 'tool': tool, + 'time': time.toIso8601String(), + }; + } +} + +class ContentFeedbackModel { + final String feedback; + final ActivitySummaryResponseModel content; + + ContentFeedbackModel({ + required this.feedback, + required this.content, + }); + + factory ContentFeedbackModel.fromJson(Map json) { + return ContentFeedbackModel( + feedback: json['feedback'] as String, + content: ActivitySummaryResponseModel.fromJson(json['content']), + ); + } + + Map toJson() { + return { + 'feedback': feedback, + 'content': content.toJson(), + }; + } +} + +class ActivitySummaryRequestModel { + final ActivityPlanModel activity; + final List activityResults; + final List contentFeedback; + + ActivitySummaryRequestModel({ + required this.activity, + required this.activityResults, + required this.contentFeedback, + }); + + Map toJson() { + return { + 'activity': activity.toJson(), + 'activity_results': activityResults.map((e) => e.toJson()).toList(), + 'content_feedback': contentFeedback.map((e) => e.toJson()).toList(), + }; + } + + factory ActivitySummaryRequestModel.fromJson(Map json) { + return ActivitySummaryRequestModel( + activity: ActivityPlanModel.fromJson(json['activity']), + activityResults: (json['activity_results'] as List) + .map((e) => ActivitySummaryResultsMessage.fromJson(e)) + .toList(), + contentFeedback: (json['content_feedback'] as List) + .map((e) => ContentFeedbackModel.fromJson(e)) + .toList(), + ); + } +} diff --git a/lib/pangea/activity_summary/activity_summary_response_model.dart b/lib/pangea/activity_summary/activity_summary_response_model.dart new file mode 100644 index 000000000..9954ad59a --- /dev/null +++ b/lib/pangea/activity_summary/activity_summary_response_model.dart @@ -0,0 +1,58 @@ +class ParticipantSummaryModel { + final String participantId; + final String feedback; + final String cefrLevel; + final List superlatives; + + ParticipantSummaryModel({ + required this.participantId, + required this.feedback, + required this.cefrLevel, + required this.superlatives, + }); + + factory ParticipantSummaryModel.fromJson(Map json) { + return ParticipantSummaryModel( + participantId: json['participant_id'] as String, + feedback: json['feedback'] as String, + cefrLevel: json['cefr_level'] as String, + superlatives: + (json['superlatives'] as List).map((e) => e as String).toList(), + ); + } + + Map toJson() { + return { + 'participant_id': participantId, + 'feedback': feedback, + 'cefr_level': cefrLevel, + 'superlatives': superlatives, + }; + } +} + +class ActivitySummaryResponseModel { + final List participants; + final String summary; + + ActivitySummaryResponseModel({ + required this.participants, + required this.summary, + }); + + factory ActivitySummaryResponseModel.fromJson(Map json) { + return ActivitySummaryResponseModel( + participants: (json['participants'] as List) + .map((e) => ParticipantSummaryModel.fromJson(e)) + .toList(), + summary: json['summary'] as String, + ); + } + + Map toJson() { + return { + 'participants': participants.map((e) => e.toJson()).toList(), + 'summary': summary, + }; + } +} diff --git a/lib/pangea/analytics_misc/get_analytics_controller.dart b/lib/pangea/analytics_misc/get_analytics_controller.dart index 9e1293ffb..20f76f096 100644 --- a/lib/pangea/analytics_misc/get_analytics_controller.dart +++ b/lib/pangea/analytics_misc/get_analytics_controller.dart @@ -108,7 +108,11 @@ class GetAnalyticsController extends BaseController { data: {}, ); } finally { - _updateAnalyticsStream(points: 0, newConstructs: []); + _updateAnalyticsStream( + type: AnalyticsUpdateType.local, + points: 0, + newConstructs: [], + ); if (!initCompleter.isCompleted) initCompleter.complete(); _initializing = false; } @@ -130,7 +134,18 @@ class GetAnalyticsController extends BaseController { Future _onAnalyticsUpdate( AnalyticsUpdate analyticsUpdate, ) async { + final validTypes = [AnalyticsUpdateType.local, AnalyticsUpdateType.server]; + if (!validTypes.contains(analyticsUpdate.type)) { + _updateAnalyticsStream( + type: analyticsUpdate.type, + points: 0, + newConstructs: [], + ); + return; + } + if (analyticsUpdate.isLogout) return; + final oldLevel = constructListModel.level; final offset = @@ -176,6 +191,7 @@ class GetAnalyticsController extends BaseController { _onUnlockMorphLemmas(newUnlockedMorphs); } _updateAnalyticsStream( + type: analyticsUpdate.type, points: analyticsUpdate.newConstructs.fold( 0, (previousValue, element) => previousValue + element.xp, @@ -193,12 +209,14 @@ class GetAnalyticsController extends BaseController { } void _updateAnalyticsStream({ + required AnalyticsUpdateType type, required int points, required List newConstructs, String? targetID, }) => analyticsStream.add( AnalyticsStreamUpdate( + type: type, points: points, newConstructs: newConstructs, targetID: targetID, @@ -570,6 +588,13 @@ class GetAnalyticsController extends BaseController { return summary; } + + List get archivedActivities { + final Room? analyticsRoom = _client.analyticsRoomLocal(_l2!); + if (analyticsRoom == null) return []; + final ids = analyticsRoom.activityRoomIds; + return ids.map((id) => _client.getRoomById(id)).whereType().toList(); + } } class AnalyticsCacheEntry { @@ -602,11 +627,13 @@ class AnalyticsCacheEntry { } class AnalyticsStreamUpdate { + final AnalyticsUpdateType type; final int points; final List newConstructs; final String? targetID; AnalyticsStreamUpdate({ + required this.type, required this.points, required this.newConstructs, this.targetID, diff --git a/lib/pangea/analytics_misc/put_analytics_controller.dart b/lib/pangea/analytics_misc/put_analytics_controller.dart index 5d4c3bf08..60e6035a9 100644 --- a/lib/pangea/analytics_misc/put_analytics_controller.dart +++ b/lib/pangea/analytics_misc/put_analytics_controller.dart @@ -15,7 +15,7 @@ import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; import 'package:fluffychat/pangea/learning_settings/models/language_model.dart'; import 'package:fluffychat/widgets/matrix.dart'; -enum AnalyticsUpdateType { server, local } +enum AnalyticsUpdateType { server, local, activities } /// handles the processing of analytics for /// 1) messages sent by the user and @@ -24,6 +24,7 @@ class PutAnalyticsController extends BaseController { late PangeaController _pangeaController; StreamController analyticsUpdateStream = StreamController.broadcast(); + StreamSubscription? _analyticsStream; StreamSubscription? _languageStream; Timer? _updateTimer; @@ -414,6 +415,41 @@ class PutAnalyticsController extends BaseController { _pangeaController.getAnalytics.locallyCachedSentConstructs, ); } + + Future sendActivityAnalytics(String roomId) async { + if (_pangeaController.matrixState.client.userID == null) return; + if (_pangeaController.languageController.userL2 == null) return; + + final Room? analyticsRoom = await _client.getMyAnalyticsRoom( + _pangeaController.languageController.userL2!, + ); + if (analyticsRoom == null) return; + await analyticsRoom.addActivityRoomId(roomId); + + analyticsUpdateStream.add( + AnalyticsUpdate( + AnalyticsUpdateType.activities, + [], + ), + ); + } + + Future removeActivityAnalytics(String roomId) async { + if (_pangeaController.matrixState.client.userID == null) return; + if (_pangeaController.languageController.userL2 == null) return; + + final Room? analyticsRoom = await _client.getMyAnalyticsRoom( + _pangeaController.languageController.userL2!, + ); + if (analyticsRoom == null) return; + await analyticsRoom.removeActivityRoomId(roomId); + analyticsUpdateStream.add( + AnalyticsUpdate( + AnalyticsUpdateType.activities, + [], + ), + ); + } } class AnalyticsStream { diff --git a/lib/pangea/analytics_misc/room_analytics_extension.dart b/lib/pangea/analytics_misc/room_analytics_extension.dart index 0412db276..4d7117e57 100644 --- a/lib/pangea/analytics_misc/room_analytics_extension.dart +++ b/lib/pangea/analytics_misc/room_analytics_extension.dart @@ -268,4 +268,51 @@ extension AnalyticsRoomExtension on Room { ); } } + + List get activityRoomIds { + final state = getState(PangeaEventTypes.activityRoomIds); + if (state?.content[ModelKey.roomIds] is List) { + return List.from(state!.content[ModelKey.roomIds] as List); + } + return []; + } + + Future addActivityRoomId(String roomId) async { + final List ids = List.from(activityRoomIds); + if (ids.contains(roomId)) return; + + final prevLength = ids.length; + ids.add(roomId); + + final syncFuture = client.waitForRoomInSync(id, join: true); + await client.setRoomStateWithKey( + id, + PangeaEventTypes.activityRoomIds, + "", + {ModelKey.roomIds: ids}, + ); + final newLength = activityRoomIds.length; + if (newLength == prevLength) { + await syncFuture; + } + } + + Future removeActivityRoomId(String roomId) async { + final List ids = List.from(activityRoomIds); + if (!ids.contains(roomId)) return; + final prevLength = ids.length; + ids.remove(roomId); + + final syncFuture = client.waitForRoomInSync(id, join: true); + await client.setRoomStateWithKey( + id, + PangeaEventTypes.activityRoomIds, + "", + {ModelKey.roomIds: ids}, + ); + final newLength = activityRoomIds.length; + if (newLength == prevLength) { + await syncFuture; + } + } } diff --git a/lib/pangea/analytics_page/activity_archive.dart b/lib/pangea/analytics_page/activity_archive.dart new file mode 100644 index 000000000..9b355d6ce --- /dev/null +++ b/lib/pangea/analytics_page/activity_archive.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; + +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/pangea/analytics_page/activity_archive_view.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +class ActivityArchive extends StatefulWidget { + const ActivityArchive({super.key}); + + @override + ActivityArchiveState createState() => ActivityArchiveState(); +} + +class ActivityArchiveState extends State { + List get archive => + MatrixState.pangeaController.getAnalytics.archivedActivities; + + Future removeArchivedChat(Room room) async { + await room.leave(); + await MatrixState.pangeaController.putAnalytics + .removeActivityAnalytics(room.id); + + setState(() {}); + } + + @override + Widget build(BuildContext context) => ActivityArchiveView(controller: this); +} diff --git a/lib/pangea/analytics_page/activity_archive_view.dart b/lib/pangea/analytics_page/activity_archive_view.dart new file mode 100644 index 000000000..ac0aee44d --- /dev/null +++ b/lib/pangea/analytics_page/activity_archive_view.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; + +import 'package:go_router/go_router.dart'; + +import 'package:fluffychat/pages/chat_list/chat_list_item.dart'; +import 'package:fluffychat/pangea/analytics_page/activity_archive.dart'; +import 'package:fluffychat/widgets/future_loading_dialog.dart'; +import 'package:fluffychat/widgets/layouts/max_width_body.dart'; + +class ActivityArchiveView extends StatelessWidget { + final ActivityArchiveState controller; + const ActivityArchiveView({ + super.key, + required this.controller, + }); + + @override + Widget build(BuildContext context) { + return MaxWidthBody( + withScrolling: false, + child: Builder( + builder: (BuildContext context) { + if (controller.archive.isEmpty) { + return const Center( + child: Icon(Icons.archive_outlined, size: 80), + ); + } + return ListView.builder( + itemCount: controller.archive.length, + itemBuilder: (BuildContext context, int i) => ChatListItem( + controller.archive[i], + onForget: () { + showFutureLoadingDialog( + context: context, + future: () => controller.removeArchivedChat( + controller.archive[i], + ), + ); + }, + onTap: () => + context.go('/rooms/analytics/${controller.archive[i].id}'), + ), + ); + }, + ), + ); + } +} diff --git a/lib/pangea/analytics_page/analytics_page_view.dart b/lib/pangea/analytics_page/analytics_page_view.dart index f0b3e5124..e9415d144 100644 --- a/lib/pangea/analytics_page/analytics_page_view.dart +++ b/lib/pangea/analytics_page/analytics_page_view.dart @@ -6,6 +6,7 @@ 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'; import 'package:fluffychat/pangea/analytics_page/analytics_page.dart'; import 'package:fluffychat/pangea/analytics_summary/learning_progress_indicators.dart'; import 'package:fluffychat/pangea/analytics_summary/level_dialog_content.dart'; @@ -67,6 +68,9 @@ class AnalyticsPageView extends StatelessWidget { constructZoom: controller.widget.constructZoom, view: ConstructTypeEnum.vocab, ); + } else if (controller.selectedIndicator == + ProgressIndicatorEnum.activities) { + return const ActivityArchive(); } return const SizedBox(); diff --git a/lib/pangea/analytics_summary/learning_progress_indicators.dart b/lib/pangea/analytics_summary/learning_progress_indicators.dart index c6d091b9b..89d453d08 100644 --- a/lib/pangea/analytics_summary/learning_progress_indicators.dart +++ b/lib/pangea/analytics_summary/learning_progress_indicators.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_list_model.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; import 'package:fluffychat/pangea/analytics_misc/get_analytics_controller.dart'; @@ -99,6 +100,8 @@ class LearningProgressIndicatorsState final userL1 = MatrixState.pangeaController.languageController.userL1; final userL2 = MatrixState.pangeaController.languageController.userL2; + final isColumnMode = FluffyThemes.isColumnMode(context); + return Row( children: [ Expanded( @@ -111,24 +114,53 @@ class LearningProgressIndicatorsState Padding( padding: const EdgeInsets.only(left: 8.0), child: Row( - spacing: 16.0, - children: ConstructTypeEnum.values - .map( - (c) => HoverButton( - selected: widget.selected == c.indicator, - onPressed: () { - context.go( - "/rooms/analytics?mode=${c.string}", - ); - }, - child: ProgressIndicatorBadge( - indicator: c.indicator, - loading: _loading, - points: uniqueLemmas(c.indicator), - ), + spacing: isColumnMode ? 16.0 : 4.0, + children: [ + ...ConstructTypeEnum.values.map( + (c) => HoverButton( + selected: widget.selected == c.indicator, + onPressed: () { + context.go( + "/rooms/analytics?mode=${c.string}", + ); + }, + child: ProgressIndicatorBadge( + indicator: c.indicator, + loading: _loading, + points: uniqueLemmas(c.indicator), ), - ) - .toList(), + ), + ), + HoverButton( + selected: widget.selected == + ProgressIndicatorEnum.activities, + onPressed: () { + context.go( + "/rooms/analytics?mode=activities", + ); + }, + child: Tooltip( + message: ProgressIndicatorEnum.activities + .tooltip(context), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + size: 18, + Icons.radar, + color: Theme.of(context).colorScheme.primary, + weight: 1000, + ), + const SizedBox(width: 6.0), + AnimatedFloatingNumber( + number: MatrixState.pangeaController + .getAnalytics.archivedActivities.length, + ), + ], + ), + ), + ), + ], ), ), HoverButton( diff --git a/lib/pangea/analytics_summary/progress_indicator.dart b/lib/pangea/analytics_summary/progress_indicator.dart index fe931d19d..579ba205f 100644 --- a/lib/pangea/analytics_summary/progress_indicator.dart +++ b/lib/pangea/analytics_summary/progress_indicator.dart @@ -30,9 +30,8 @@ class ProgressIndicatorBadge extends StatelessWidget { ), const SizedBox(width: 6.0), !loading - ? _AnimatedFloatingNumber( + ? AnimatedFloatingNumber( number: points, - indicator: indicator, ) : const SizedBox( height: 8, @@ -47,21 +46,19 @@ class ProgressIndicatorBadge extends StatelessWidget { } } -class _AnimatedFloatingNumber extends StatefulWidget { +class AnimatedFloatingNumber extends StatefulWidget { final int number; - final ProgressIndicatorEnum indicator; - const _AnimatedFloatingNumber({ + const AnimatedFloatingNumber({ + super.key, required this.number, - required this.indicator, }); @override - State<_AnimatedFloatingNumber> createState() => - _AnimatedFloatingNumberState(); + State createState() => AnimatedFloatingNumberState(); } -class _AnimatedFloatingNumberState extends State<_AnimatedFloatingNumber> +class AnimatedFloatingNumberState extends State with SingleTickerProviderStateMixin { late AnimationController _controller; late Animation _fadeAnim; @@ -85,7 +82,7 @@ class _AnimatedFloatingNumberState extends State<_AnimatedFloatingNumber> } @override - void didUpdateWidget(covariant _AnimatedFloatingNumber oldWidget) { + void didUpdateWidget(covariant AnimatedFloatingNumber oldWidget) { super.didUpdateWidget(oldWidget); if (widget.number > _lastNumber!) { _floatingNumber = widget.number; @@ -109,7 +106,7 @@ class _AnimatedFloatingNumberState extends State<_AnimatedFloatingNumber> final TextStyle indicatorStyle = TextStyle( fontSize: 14, fontWeight: FontWeight.bold, - color: widget.indicator.color(context), + color: Theme.of(context).colorScheme.primary, ); return Stack( alignment: Alignment.center, diff --git a/lib/pangea/analytics_summary/progress_indicators_enum.dart b/lib/pangea/analytics_summary/progress_indicators_enum.dart index c0322af9b..b5b5dd10a 100644 --- a/lib/pangea/analytics_summary/progress_indicators_enum.dart +++ b/lib/pangea/analytics_summary/progress_indicators_enum.dart @@ -8,7 +8,8 @@ import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; enum ProgressIndicatorEnum { level, wordsUsed, - morphsUsed; + morphsUsed, + activities; static ProgressIndicatorEnum? fromString(String value) { switch (value) { @@ -18,6 +19,8 @@ enum ProgressIndicatorEnum { return ProgressIndicatorEnum.morphsUsed; case 'level': return ProgressIndicatorEnum.level; + case 'activities': + return ProgressIndicatorEnum.activities; default: return null; } @@ -33,6 +36,8 @@ extension ProgressIndicatorsExtension on ProgressIndicatorEnum { return Symbols.toys_and_games; case ProgressIndicatorEnum.level: return Icons.star; + case ProgressIndicatorEnum.activities: + return Icons.radar; } } @@ -51,6 +56,8 @@ extension ProgressIndicatorsExtension on ProgressIndicatorEnum { return L10n.of(context).level; case ProgressIndicatorEnum.morphsUsed: return L10n.of(context).grammar; + case ProgressIndicatorEnum.activities: + return L10n.of(context).activities; } } diff --git a/lib/pangea/chat/constants/default_power_level.dart b/lib/pangea/chat/constants/default_power_level.dart index cc354b346..9b9b4f471 100644 --- a/lib/pangea/chat/constants/default_power_level.dart +++ b/lib/pangea/chat/constants/default_power_level.dart @@ -13,6 +13,8 @@ class RoomDefaults { "redact": 50, "events": { PangeaEventTypes.activityPlan: 0, + PangeaEventTypes.activityRole: 0, + PangeaEventTypes.activitySummary: 0, "m.room.power_levels": 100, "m.room.pinned_events": 50, }, @@ -38,6 +40,8 @@ class RoomDefaults { "redact": 50, "events": { PangeaEventTypes.activityPlan: 50, + PangeaEventTypes.activityRole: 0, + PangeaEventTypes.activitySummary: 0, "m.room.power_levels": 100, "m.room.pinned_events": 50, }, diff --git a/lib/pangea/chat/widgets/activity_role_state_message.dart b/lib/pangea/chat/widgets/activity_role_state_message.dart new file mode 100644 index 000000000..73e526b2c --- /dev/null +++ b/lib/pangea/chat/widgets/activity_role_state_message.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; + +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/l10n/l10n.dart'; +import '../../../config/app_config.dart'; + +class ActivityRoleStateMessage extends StatelessWidget { + final Event event; + const ActivityRoleStateMessage(this.event, {super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final senderName = event.senderId == event.room.client.userID + ? L10n.of(context).you + : event.senderFromMemoryOrFallback.calcDisplayname(); + + String role = L10n.of(context).participant; + bool finished = false; + + try { + final roleContent = event.content['role'] as String?; + if (roleContent != null) { + role = roleContent; + } + + finished = event.content['finishedAt'] != null; + } catch (e) { + // If the role is not found, we keep the default participant role. + } + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: Center( + child: Padding( + padding: const EdgeInsets.all(4), + child: Material( + color: theme.colorScheme.surface.withAlpha(128), + borderRadius: BorderRadius.circular(AppConfig.borderRadius / 3), + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), + child: Text( + finished + ? L10n.of(context).finishedTheActivity(senderName) + : L10n.of(context).joinedTheActivity(senderName, role), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12 * AppConfig.fontSizeFactor, + decoration: + event.redacted ? TextDecoration.lineThrough : null, + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/pangea/chat/widgets/activity_state_event.dart b/lib/pangea/chat/widgets/activity_state_event.dart new file mode 100644 index 000000000..34f2bd764 --- /dev/null +++ b/lib/pangea/chat/widgets/activity_state_event.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; + +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/pages/chat/events/state_message.dart'; +import 'package:fluffychat/pangea/activity_planner/activity_participant_indicator.dart'; +import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart'; +import 'package:fluffychat/pangea/activity_planner/activity_room_extension.dart'; + +class ActivityStateEvent extends StatelessWidget { + final Event event; + const ActivityStateEvent({super.key, required this.event}); + + @override + Widget build(BuildContext context) { + try { + final activity = ActivityPlanModel.fromJson(event.content); + final roles = event.room.activityRoles; + + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: 24.0, + vertical: 16.0, + ), + child: Column( + spacing: 12.0, + children: [ + Text(activity.markdown), + if (roles.isNotEmpty) + Wrap( + spacing: 12.0, + runSpacing: 12.0, + children: event.room.activityRoles.map((role) { + return ActivityParticipantIndicator( + role: role, + displayname: role.userId.localpart, + ); + }).toList(), + ), + ], + ), + ); + } catch (e) { + return StateMessage(event); + } + } +} diff --git a/lib/pangea/chat_settings/pages/pangea_chat_details.dart b/lib/pangea/chat_settings/pages/pangea_chat_details.dart index 861ca3933..0f9bf2a3a 100644 --- a/lib/pangea/chat_settings/pages/pangea_chat_details.dart +++ b/lib/pangea/chat_settings/pages/pangea_chat_details.dart @@ -321,9 +321,9 @@ class RoomDetailsButtonRowState extends State { title: l10n.activities, icon: const Icon(Icons.event_note_outlined, size: 30.0), onPressed: () => context.go("/rooms/${room.id}/details/planner"), - visible: room.canChangeStateEvent(PangeaEventTypes.activityPlan) || + visible: room.isSpace, + enabled: room.canChangeStateEvent(PangeaEventTypes.activityPlan) && room.isSpace, - enabled: room.canChangeStateEvent(PangeaEventTypes.activityPlan), ), ButtonDetails( title: l10n.permissions, diff --git a/lib/pangea/common/constants/model_keys.dart b/lib/pangea/common/constants/model_keys.dart index 0f9e33f54..e062ffa33 100644 --- a/lib/pangea/common/constants/model_keys.dart +++ b/lib/pangea/common/constants/model_keys.dart @@ -179,4 +179,5 @@ class ModelKey { "number_of_participants"; static const String autoIGC = "auto_igc"; + static const String roomIds = "room_ids"; } diff --git a/lib/pangea/common/controllers/pangea_controller.dart b/lib/pangea/common/controllers/pangea_controller.dart index 4fe1a747d..2699bcde4 100644 --- a/lib/pangea/common/controllers/pangea_controller.dart +++ b/lib/pangea/common/controllers/pangea_controller.dart @@ -61,7 +61,7 @@ class PangeaController { int? randomint; PangeaController({required this.matrix, required this.matrixState}) { _setup(); - _setLanguageStream(); + _setLanguageSubscription(); randomint = Random().nextInt(2000); } @@ -184,7 +184,7 @@ class PangeaController { // Initialize analytics data putAnalytics.initialize(); getAnalytics.initialize(); - _setLanguageStream(); + _setLanguageSubscription(); userController.reinitialize().then((_) { final l1 = userController.profile.userSettings.sourceLanguage; @@ -212,7 +212,7 @@ class PangeaController { await getAnalytics.initialize(); } - void _setLanguageStream() { + void _setLanguageSubscription() { _languageStream?.cancel(); _languageStream = userController.languageStream.stream.listen( (_) => clearCache(exclude: ["analytics_storage"]), diff --git a/lib/pangea/common/network/urls.dart b/lib/pangea/common/network/urls.dart index f0a450c4d..ad387d769 100644 --- a/lib/pangea/common/network/urls.dart +++ b/lib/pangea/common/network/urls.dart @@ -65,6 +65,7 @@ class PApiUrls { static String topicList = "${PApiUrls.choreoEndpoint}/topics"; static String activityPlanSearch = "${PApiUrls.choreoEndpoint}/activity_plan/search"; + static String activitySummary = "${PApiUrls.choreoEndpoint}/activity_summary"; static String morphFeaturesAndTags = "${PApiUrls.choreoEndpoint}/morphs"; static String constructSummary = diff --git a/lib/pangea/events/constants/pangea_event_types.dart b/lib/pangea/events/constants/pangea_event_types.dart index fe2a47e18..93349ae11 100644 --- a/lib/pangea/events/constants/pangea_event_types.dart +++ b/lib/pangea/events/constants/pangea_event_types.dart @@ -26,6 +26,8 @@ class PangeaEventTypes { static const capacity = "pangea.capacity"; static const activityPlan = "pangea.activity_plan"; + static const activityRole = "pangea.activity_roles"; + static const activitySummary = "pangea.activity_summary"; static const userAge = "pangea.user_age"; @@ -44,4 +46,6 @@ class PangeaEventTypes { /// Profile information related to a user's analytics static const profileAnalytics = "pangea.analytics_profile"; + + static const activityRoomIds = "pangea.activity_room_ids"; } diff --git a/lib/pangea/extensions/pangea_room_extension.dart b/lib/pangea/extensions/pangea_room_extension.dart index 316ef7199..a3c9872d3 100644 --- a/lib/pangea/extensions/pangea_room_extension.dart +++ b/lib/pangea/extensions/pangea_room_extension.dart @@ -9,24 +9,27 @@ import 'package:flutter/material.dart'; import 'package:collection/collection.dart'; import 'package:html_unescape/html_unescape.dart'; -import 'package:http/http.dart' as http; import 'package:matrix/matrix.dart'; import 'package:matrix/src/utils/markdown.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; -import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart'; -import 'package:fluffychat/pangea/activity_planner/bookmarked_activities_repo.dart'; +import 'package:fluffychat/l10n/l10n.dart'; +import 'package:fluffychat/pangea/activity_planner/activity_room_extension.dart'; import 'package:fluffychat/pangea/analytics_misc/constructs_event.dart'; import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart'; import 'package:fluffychat/pangea/bot/utils/bot_name.dart'; +import 'package:fluffychat/pangea/chat/constants/default_power_level.dart'; import 'package:fluffychat/pangea/chat_settings/constants/bot_mode.dart'; import 'package:fluffychat/pangea/chat_settings/constants/pangea_room_types.dart'; import 'package:fluffychat/pangea/chat_settings/models/bot_options_model.dart'; import 'package:fluffychat/pangea/common/constants/model_keys.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/events/models/tokens_event_content_model.dart'; +import 'package:fluffychat/pangea/extensions/join_rule_extension.dart'; import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; import 'package:fluffychat/pangea/spaces/constants/space_constants.dart'; +import 'package:fluffychat/widgets/adaptive_dialogs/show_text_input_dialog.dart'; +import 'package:fluffychat/widgets/future_loading_dialog.dart'; import '../choreographer/models/choreo_record.dart'; import '../events/constants/pangea_event_types.dart'; import '../events/models/representation_content_model.dart'; diff --git a/lib/pangea/extensions/room_children_and_parents_extension.dart b/lib/pangea/extensions/room_children_and_parents_extension.dart index 3a8fe73b8..1b669f4f6 100644 --- a/lib/pangea/extensions/room_children_and_parents_extension.dart +++ b/lib/pangea/extensions/room_children_and_parents_extension.dart @@ -94,8 +94,53 @@ extension ChildrenAndParentsRoomExtension on Room { int get spaceChildCount => client.rooms .where( (r) => spaceChildren.any( - (child) => r.id == child.roomId && !r.isAnalyticsRoom, + (child) => r.id == child.roomId && !r.isHiddenRoom, ), ) .length; + + Future addSubspace(BuildContext context) async { + if (!isSpace) return; + final names = await showTextInputDialog( + context: context, + title: L10n.of(context).createNewSpace, + hintText: L10n.of(context).spaceName, + 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; + await showFutureLoadingDialog( + context: context, + future: () async { + await postLoad(); + final resp = await client.createRoom( + name: names, + visibility: RoomDefaults.spaceChildVisibility, + creationContent: {'type': 'm.space'}, + initialState: [ + RoomDefaults.defaultSpacePowerLevels(client.userID!), + await client.pangeaJoinRules( + 'knock_restricted', + allow: [ + { + "type": "m.room_membership", + "room_id": id, + } + ], + ), + ], + ); + await addToSpace(resp); + }, + ); + } } diff --git a/lib/pangea/extensions/room_events_extension.dart b/lib/pangea/extensions/room_events_extension.dart index 65adf4dd2..9dfbd7f2d 100644 --- a/lib/pangea/extensions/room_events_extension.dart +++ b/lib/pangea/extensions/room_events_extension.dart @@ -10,7 +10,7 @@ extension EventsRoomExtension on Room { for (final child in spaceChildren) { if (child.roomId == null) continue; final Room? room = client.getRoomById(child.roomId!); - if (room == null || room.isAnalyticsRoom) continue; + if (room == null || room.isHiddenRoom) continue; try { await room.leave(); } catch (e, s) { @@ -274,78 +274,6 @@ extension EventsRoomExtension on Room { ); } - Future sendActivityPlan( - ActivityPlanModel activity, { - Uint8List? avatar, - String? filename, - }) async { - BookmarkedActivitiesRepo.save(activity); - - String? imageURL = activity.imageURL; - final eventId = await pangeaSendTextEvent( - activity.markdown, - messageTag: ModelKey.messageTagActivityPlan, - ); - - Uint8List? bytes = avatar; - if (imageURL != null && bytes == null) { - try { - final resp = await http - .get(Uri.parse(imageURL)) - .timeout(const Duration(seconds: 5)); - bytes = resp.bodyBytes; - } catch (e, s) { - ErrorHandler.logError( - e: e, - s: s, - data: { - "avatarURL": imageURL, - }, - ); - } - } - - if (bytes != null && imageURL == null) { - final url = await client.uploadContent( - bytes, - filename: filename, - ); - imageURL = url.toString(); - } - - MatrixFile? file; - if (filename != null && bytes != null) { - file = MatrixFile( - bytes: bytes, - name: filename, - ); - } - - if (file != null) { - final content = { - 'msgtype': file.msgType, - 'body': file.name, - 'filename': file.name, - 'url': imageURL, - ModelKey.messageTags: ModelKey.messageTagActivityPlan, - }; - await sendEvent(content); - } - - if (canChangeStateEvent(PangeaEventTypes.activityPlan)) { - await client.setRoomStateWithKey( - id, - PangeaEventTypes.activityPlan, - "", - activity.toJson(), - ); - - if (eventId != null && canChangeStateEvent(EventTypes.RoomPinnedEvents)) { - await setPinnedEvents([eventId]); - } - } - } - /// Get a list of events in the room that are of type [PangeaEventTypes.construct] /// and have the sender as [userID]. If [count] is provided, the function will /// return at most [count] events. diff --git a/lib/pangea/extensions/room_information_extension.dart b/lib/pangea/extensions/room_information_extension.dart index f206a050c..3e1decb0b 100644 --- a/lib/pangea/extensions/room_information_extension.dart +++ b/lib/pangea/extensions/room_information_extension.dart @@ -34,4 +34,9 @@ extension RoomInformationRoomExtension on Room { bool get isAnalyticsRoom => getState(EventTypes.RoomCreate)?.content.tryGet('type') == PangeaRoomTypes.analytics; + + bool get isHiddenActivityRoom => + activityRole(client.userID!)?.isArchived ?? false; + + bool get isHiddenRoom => isAnalyticsRoom || isHiddenActivityRoom; } diff --git a/lib/pangea/extensions/room_settings_extension.dart b/lib/pangea/extensions/room_settings_extension.dart index b0c7fbce3..e3778685f 100644 --- a/lib/pangea/extensions/room_settings_extension.dart +++ b/lib/pangea/extensions/room_settings_extension.dart @@ -14,53 +14,10 @@ extension RoomSettingsRoomExtension on Room { return t is int ? t : null; } - IconData? get roomTypeIcon { - if (membership == Membership.invite) return Icons.add; - if (isSpace) return Icons.school; - if (isAnalyticsRoom) return Icons.analytics; - if (isDirectChat) return Icons.forum; - return Icons.group; - } - - Text nameAndRoomTypeIcon([TextStyle? textStyle]) => Text.rich( - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: textStyle, - TextSpan( - children: [ - WidgetSpan( - child: Icon(roomTypeIcon), - ), - TextSpan( - text: ' $name', - ), - ], - ), - ); - BotOptionsModel? get botOptions { if (isSpace) return null; final stateEvent = getState(PangeaEventTypes.botOptions); if (stateEvent == null) return null; return BotOptionsModel.fromJson(stateEvent.content); } - - ActivityPlanModel? get activityPlan { - final stateEvent = getState(PangeaEventTypes.activityPlan); - if (stateEvent == null) return null; - - try { - return ActivityPlanModel.fromJson(stateEvent.content); - } catch (e, s) { - ErrorHandler.logError( - e: e, - s: s, - data: { - "roomID": id, - "stateEvent": stateEvent.content, - }, - ); - return null; - } - } } diff --git a/lib/pangea/spaces/widgets/space_view_appbar.dart b/lib/pangea/spaces/widgets/space_view_appbar.dart new file mode 100644 index 000000000..1faced723 --- /dev/null +++ b/lib/pangea/spaces/widgets/space_view_appbar.dart @@ -0,0 +1,146 @@ +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 b6ff1a493..3dceed6d8 100644 --- a/lib/utils/client_manager.dart +++ b/lib/utils/client_manager.dart @@ -136,7 +136,10 @@ abstract class ClientManager { PangeaEventTypes.userSetLemmaInfo, EventTypes.RoomJoinRules, PangeaEventTypes.activityPlan, + PangeaEventTypes.activityRole, + PangeaEventTypes.activitySummary, PangeaEventTypes.constructSummary, + PangeaEventTypes.activityRoomIds, // Pangea# }, logLevel: kReleaseMode ? Level.warning : Level.verbose, diff --git a/lib/utils/matrix_sdk_extensions/filtered_timeline_extension.dart b/lib/utils/matrix_sdk_extensions/filtered_timeline_extension.dart index fee1b3c62..f946d81b9 100644 --- a/lib/utils/matrix_sdk_extensions/filtered_timeline_extension.dart +++ b/lib/utils/matrix_sdk_extensions/filtered_timeline_extension.dart @@ -1,36 +1,13 @@ import 'package:matrix/matrix.dart'; import 'package:fluffychat/pangea/common/constants/model_keys.dart'; +import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart'; import '../../config/app_config.dart'; extension VisibleInGuiExtension on List { - List filterByVisibleInGui({String? exceptionEventId}) { - final visibleEvents = - where((e) => e.isVisibleInGui || e.eventId == exceptionEventId) - .toList(); - - // Hide creation state events: - if (visibleEvents.isNotEmpty && - visibleEvents.last.type == EventTypes.RoomCreate) { - var i = visibleEvents.length - 2; - while (i > 0) { - final event = visibleEvents[i]; - if (!event.isState) break; - if (event.type == EventTypes.Encryption) { - i--; - continue; - } - if (event.type == EventTypes.RoomMember && - event.roomMemberChangeType == RoomMemberChangeType.acceptInvite) { - i--; - continue; - } - visibleEvents.removeAt(i); - i--; - } - } - return visibleEvents; - } + List filterByVisibleInGui({String? exceptionEventId}) => where( + (event) => event.isVisibleInGui || event.eventId == exceptionEventId, + ).toList(); } extension IsStateExtension on Event { @@ -46,7 +23,10 @@ extension IsStateExtension on Event { // if we enabled to hide all redacted events, don't show those (!AppConfig.hideRedactedEvents || !redacted) && // if we enabled to hide all unknown events, don't show those - (!AppConfig.hideUnknownEvents || isEventTypeKnown) && + // #Pangea + // (!AppConfig.hideUnknownEvents || isEventTypeKnown) && + (!AppConfig.hideUnknownEvents || pangeaIsEventTypeKnown) && + // Pangea# // remove state events that we don't want to render (isState || !AppConfig.hideAllStateEvents) && // #Pangea @@ -75,6 +55,13 @@ extension IsStateExtension on Event { }.contains(type); // #Pangea + bool get pangeaIsEventTypeKnown => + isEventTypeKnown || + [ + PangeaEventTypes.activityPlan, + PangeaEventTypes.activityRole, + ].contains(type); + // we're filtering out some state events that we don't want to render static const Set importantStateEvents = { EventTypes.Encryption, @@ -82,6 +69,8 @@ extension IsStateExtension on Event { EventTypes.RoomMember, EventTypes.RoomTombstone, EventTypes.CallInvite, + PangeaEventTypes.activityPlan, + PangeaEventTypes.activityRole, }; // Pangea# } diff --git a/lib/widgets/share_scaffold_dialog.dart b/lib/widgets/share_scaffold_dialog.dart index ec110abd5..b7a63c0bc 100644 --- a/lib/widgets/share_scaffold_dialog.dart +++ b/lib/widgets/share_scaffold_dialog.dart @@ -73,7 +73,7 @@ class _ShareScaffoldDialogState extends State { room.canSendDefaultMessages && !room.isSpace && // #Pangea - !room.isAnalyticsRoom && + !room.isHiddenRoom && // Pangea# room.membership == Membership.join, ) diff --git a/lib/widgets/unread_rooms_badge.dart b/lib/widgets/unread_rooms_badge.dart index 1d3eb7907..508b3ef49 100644 --- a/lib/widgets/unread_rooms_badge.dart +++ b/lib/widgets/unread_rooms_badge.dart @@ -26,7 +26,7 @@ class UnreadRoomsBadge extends StatelessWidget { .client .rooms // #Pangea - .where((r) => !r.isAnalyticsRoom) + .where((r) => !r.isHiddenRoom) // Pangea# .where(filter) .where((r) => (r.isUnread || r.membership == Membership.invite))