diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index c801d94ce..e4ebbbed4 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -4938,5 +4938,23 @@ "addEnvironmentOverride": "Add environment override", "defaultOption": "Default", "deleteChatDesc": "Are you sure you want to delete this chat? It will be deleted for all participants and all messages within the chat will no longer be available for practice or learning analytics.", - "deleteSpaceDesc": "The space and any selected chats and/or subspaces will be deleted for all participants and all messages within the chat will no longer be available for practice or learning analytics. This action cannot be undone." + "deleteSpaceDesc": "The space and any selected chats and/or subspaces will be deleted for all participants and all messages within the chat will no longer be available for practice or learning analytics. This action cannot be undone.", + "chatWithActivities": "Chat with activities", + "findYourPeople": "Find your people", + "launch": "Launch", + "launchActivityToChats": "Launch activity to chats", + "searchChats": "Search chats", + "selectChats": "Select chats", + "selectChatToStart": "Complete! Select a chat to start", + "configureSpace": "Configure space", + "pinMessages": "Pin messages", + "setJoinRules": "Set join rules", + "displayNavigationRail": "Show navigation rail on mobile", + "changeGeneralSettings": "Change general settings", + "inviteOtherUsersToRoom": "Invite other users", + "changeTheNameOfTheSpace": "Change the name of the space", + "changeTheDescription": "Change the description", + "changeThePermissions": "Change the permissions", + "introductions": "Introductions", + "announcements": "Announcements" } diff --git a/lib/config/app_config.dart b/lib/config/app_config.dart index 0fbd529c2..d04d20be0 100644 --- a/lib/config/app_config.dart +++ b/lib/config/app_config.dart @@ -121,7 +121,10 @@ abstract class AppConfig { 'https://8591d0d863b646feb4f3dda7e5dcab38@o256755.ingest.sentry.io/5243143'; // Pangea# static bool renderHtml = true; - static bool hideRedactedEvents = false; + // #Pangea + // static bool hideRedactedEvents = false; + static bool hideRedactedEvents = true; + // Pangea# static bool hideUnknownEvents = true; static bool hideUnimportantStateEvents = true; static bool separateChatTypes = false; @@ -131,6 +134,9 @@ abstract class AppConfig { static bool swipeRightToLeftToReply = true; static bool? sendOnEnter; static bool showPresences = true; + // #Pangea + static bool displayNavigationRail = true; + // Pangea# static bool experimentalVoip = false; static const bool hideTypingUsernames = false; static const bool hideAllStateEvents = false; diff --git a/lib/config/routes.dart b/lib/config/routes.dart index bc4b0eaa6..e1d6bd72a 100644 --- a/lib/config/routes.dart +++ b/lib/config/routes.dart @@ -33,7 +33,6 @@ import 'package:fluffychat/pangea/activity_generator/activity_generator.dart'; import 'package:fluffychat/pangea/activity_planner/activity_planner_page.dart'; import 'package:fluffychat/pangea/activity_suggestions/suggestions_page.dart'; import 'package:fluffychat/pangea/guard/p_vguard.dart'; -import 'package:fluffychat/pangea/layouts/bottom_nav_layout.dart'; import 'package:fluffychat/pangea/learning_settings/pages/settings_learning.dart'; import 'package:fluffychat/pangea/login/pages/login_or_signup_view.dart'; import 'package:fluffychat/pangea/login/pages/signup.dart'; @@ -188,32 +187,6 @@ abstract class AppRoutes { ), ], ), - ShellRoute( - pageBuilder: chatListShellRouteBuilder, - routes: [ - GoRoute( - path: '/homepage', - redirect: loggedOutRedirect, - pageBuilder: (context, state) => defaultPageBuilder( - context, - state, - const SuggestionsPage(), - ), - routes: [ - ...newRoomRoutes, - GoRoute( - path: '/planner', - redirect: loggedOutRedirect, - pageBuilder: (context, state) => defaultPageBuilder( - context, - state, - const ActivityGenerator(), - ), - ), - ], - ), - ], - ), // Pangea# ShellRoute( // Never use a transition on the shell route. Changing the PageBuilder @@ -236,15 +209,7 @@ abstract class AppRoutes { ), sideView: child, ) - // #Pangea - // : child, - : FluffyThemes.isColumnMode(context) || - (state.fullPath?.split("/").reversed.elementAt(1) == - 'rooms' && - state.pathParameters['roomid'] != null) - ? child - : BottomNavLayout(mainView: child), - // Pangea# + : child, ), routes: [ GoRoute( @@ -378,6 +343,39 @@ abstract class AppRoutes { ), redirect: loggedOutRedirect, ), + // #Pangea + GoRoute( + path: 'homepage', + redirect: loggedOutRedirect, + pageBuilder: (context, state) => defaultPageBuilder( + context, + state, + const SuggestionsPage(), + ), + routes: [ + ...newRoomRoutes, + GoRoute( + path: '/planner', + pageBuilder: (context, state) => defaultPageBuilder( + context, + state, + const ActivityPlannerPage(), + ), + redirect: loggedOutRedirect, + routes: [ + GoRoute( + path: '/generator', + redirect: loggedOutRedirect, + pageBuilder: (context, state) => defaultPageBuilder( + context, + state, + const ActivityGenerator(), + ), + ), + ], + ), + ], + ), // Pangea# ShellRoute( pageBuilder: (context, state, child) => defaultPageBuilder( @@ -794,21 +792,14 @@ abstract class AppRoutes { ? TwoColumnLayout( mainView: ChatList( activeChat: state.pathParameters['roomid'], - // #Pangea activeSpaceId: state.uri.queryParameters['spaceId'], activeFilter: state.uri.queryParameters['filter'], - // Pangea# displayNavigationRail: state.path?.startsWith('/rooms/settings') != true, ), sideView: child, ) - : FluffyThemes.isColumnMode(context) || - (state.fullPath?.split("/").reversed.elementAt(1) == - 'rooms' && - state.pathParameters['roomid'] != null) - ? child - : BottomNavLayout(mainView: child), + : child, ); // Pangea# } diff --git a/lib/config/setting_keys.dart b/lib/config/setting_keys.dart index 9e82c6b38..8b7bae372 100644 --- a/lib/config/setting_keys.dart +++ b/lib/config/setting_keys.dart @@ -32,6 +32,10 @@ abstract class SettingKeys { 'chat.fluffy.swipeRightToLeftToReply'; static const String experimentalVoip = 'chat.fluffy.experimental_voip'; static const String showPresences = 'chat.fluffy.show_presences'; + // #Pangea + static const String displayNavigationRail = + 'chat.fluffy.display_navigation_rail'; + // Pangea# } enum AppSettings { diff --git a/lib/pages/chat/chat.dart b/lib/pages/chat/chat.dart index 85577e8e0..23c27b682 100644 --- a/lib/pages/chat/chat.dart +++ b/lib/pages/chat/chat.dart @@ -708,7 +708,9 @@ class ChatController extends State } void _onRouteChanged() { - stopMediaStream.add(null); + if (!stopMediaStream.isClosed) { + stopMediaStream.add(null); + } MatrixState.pAnyState.closeAllOverlays(); } diff --git a/lib/pages/chat/chat_view.dart b/lib/pages/chat/chat_view.dart index 127ae2c40..1a366e6da 100644 --- a/lib/pages/chat/chat_view.dart +++ b/lib/pages/chat/chat_view.dart @@ -400,7 +400,12 @@ class ChatView extends StatelessWidget { // #Pangea // Keep messages above minimum input bar height if (!controller.room.isAbandonedDMRoom) - SizedBox(height: controller.inputBarHeight), + AnimatedSize( + duration: const Duration(milliseconds: 200), + child: SizedBox( + height: controller.inputBarHeight, + ), + ), // Pangea# ], ), diff --git a/lib/pages/chat/events/html_message.dart b/lib/pages/chat/events/html_message.dart index 055f6b9f9..75ed9ffb4 100644 --- a/lib/pages/chat/events/html_message.dart +++ b/lib/pages/chat/events/html_message.dart @@ -162,65 +162,68 @@ class HtmlMessage extends StatelessWidget { String fullHtml, List remainingTokens, ) { - for (final node in element.nodes) { - node.replaceWith(_tokenizeHtml(node, fullHtml, remainingTokens)); - } + final regex = RegExp(r'(<[^>]+>)'); - if (element is dom.Text) { - // once a text element in reached in the HTML tree, find and - // wrap all the spans with matching tokens until all tokens - // have been wrapped or no more text elements remain - String tokenizedText = element.text; - while (remainingTokens.isNotEmpty) { - final tokenText = remainingTokens.first.text.content; + final matches = regex.allMatches(fullHtml); + final List result = []; + int lastEnd = 0; - int startIndex = tokenizedText.lastIndexOf(''); - startIndex = startIndex == -1 ? 0 : startIndex + 8; - final int tokenIndex = tokenizedText.indexOf( - tokenText, - startIndex, - ); - - // if the token is not found in the text, check if the token exist in the full HTML. - // If not, remove the token and continue. If so, break to move on to the next node in the HTML. - if (tokenIndex == -1) { - final fullHtmlIndex = fullHtml.indexOf(tokenText); - if (fullHtmlIndex == -1) { - remainingTokens.removeAt(0); - continue; - } else { - break; - } - } - - final token = remainingTokens.removeAt(0); - final tokenEnd = tokenIndex + tokenText.length; - final before = tokenizedText.substring(0, tokenIndex); - final after = tokenizedText.substring(tokenEnd); - - tokenizedText = - "$before$tokenText$after"; + for (final match in matches) { + if (match.start > lastEnd) { + result.add(fullHtml.substring(lastEnd, match.start)); // Text before tag } - - final newElement = dom.Element.html('$tokenizedText'); - return newElement; + result.add(match.group(0)!); // The tag itself + lastEnd = match.end; } - return element; + if (lastEnd < fullHtml.length) { + result.add(fullHtml.substring(lastEnd)); // Remaining text after last tag + } + + for (final PangeaToken token in tokens ?? []) { + final String tokenText = token.text.content; + final substringIndex = result.indexWhere( + (string) => + string.contains(tokenText) && + !(string.startsWith('<') && string.endsWith('>')), + ); + + if (substringIndex == -1) continue; + final int tokenIndex = result[substringIndex].indexOf(tokenText); + if (tokenIndex == -1) continue; + + final int tokenLength = tokenText.characters.length; + final before = result[substringIndex].substring(0, tokenIndex); + final after = result[substringIndex].substring(tokenIndex + tokenLength); + result.replaceRange(substringIndex, substringIndex + 1, [ + if (before.isNotEmpty) before, + '$tokenText', + if (after.isNotEmpty) after, + ]); + } + + return dom.Element.html('${result.join()}'); } // Pangea# /// Adding line breaks before block elements. List _renderWithLineBreaks( dom.NodeList nodes, - BuildContext context, { + // #Pangea + // BuildContext context, { + BuildContext context, + TextStyle textStyle, { + // Pangea# int depth = 1, }) { final onlyElements = nodes.whereType().toList(); return [ for (var i = 0; i < nodes.length; i++) ...[ // Actually render the node child: - _renderHtml(nodes[i], context, depth: depth + 1), + // #Pangea + // _renderHtml(nodes[i], context, depth: depth + 1), + _renderHtml(nodes[i], context, textStyle, depth: depth + 1), + // Pangea# // Add linebreaks between blocks: if (nodes[i] is dom.Element && onlyElements.indexOf(nodes[i] as dom.Element) < @@ -237,7 +240,11 @@ class HtmlMessage extends StatelessWidget { /// Transforms a Node to an InlineSpan. InlineSpan _renderHtml( dom.Node node, - BuildContext context, { + // #Pangea + // BuildContext context, { + BuildContext context, + TextStyle textStyle, { + // Pangea# int depth = 1, }) { // We must not render elements nested more than 100 elements deep: @@ -276,9 +283,11 @@ class HtmlMessage extends StatelessWidget { final renderer = TokenRenderingUtil( pangeaMessageEvent: pangeaMessageEvent, readingAssistanceMode: readingAssistanceMode, - existingStyle: AppConfig.messageTextStyle( - pangeaMessageEvent!.event, - textColor, + existingStyle: textStyle.merge( + AppConfig.messageTextStyle( + pangeaMessageEvent!.event, + textColor, + ), ), overlayController: overlayController, isTransitionAnimation: isTransitionAnimation, @@ -418,6 +427,11 @@ class HtmlMessage extends StatelessWidget { children: _renderWithLineBreaks( node.nodes, context, + // #Pangea + textStyle.merge( + linkStyle.copyWith(height: 1.25), + ), + // Pangea# depth: depth, ), style: linkStyle, @@ -450,6 +464,9 @@ class HtmlMessage extends StatelessWidget { ..._renderWithLineBreaks( node.nodes, context, + // #Pangea + textStyle, + // Pangea# depth: depth, ), ], @@ -478,6 +495,9 @@ class HtmlMessage extends StatelessWidget { children: _renderWithLineBreaks( node.nodes, context, + // #Pangea + textStyle.copyWith(fontStyle: FontStyle.italic), + // Pangea# depth: depth, ), ), @@ -576,12 +596,28 @@ class HtmlMessage extends StatelessWidget { node.localName == 'summary', ) .map( - (node) => _renderHtml(node, context, depth: depth), + // #Pangea + // (node) => _renderHtml(node, context, depth: depth), + (node) => _renderHtml( + node, + context, + textStyle.merge( + TextStyle( + fontSize: fontSize, + color: textColor, + ), + ), + depth: depth, + ), + // Pangea# ) else ..._renderWithLineBreaks( node.nodes, context, + // #Pangea + textStyle, + // Pangea# depth: depth, ), ], @@ -614,6 +650,11 @@ class HtmlMessage extends StatelessWidget { children: _renderWithLineBreaks( node.nodes, context, + // #Pangea + textStyle.copyWith( + backgroundColor: obscure ? textColor : null, + ), + // Pangea# depth: depth, ), ), @@ -628,6 +669,36 @@ class HtmlMessage extends StatelessWidget { ); block: default: + // #Pangea + final style = switch (node.localName) { + 'body' => TextStyle( + fontSize: fontSize, + color: textColor, + ), + 'a' => linkStyle, + 'strong' => const TextStyle(fontWeight: FontWeight.bold), + 'em' || 'i' => const TextStyle(fontStyle: FontStyle.italic), + 'del' || + 'strikethrough' => + const TextStyle(decoration: TextDecoration.lineThrough), + 'u' => const TextStyle(decoration: TextDecoration.underline), + 'h1' => TextStyle(fontSize: fontSize * 1.6, height: 2), + 'h2' => TextStyle(fontSize: fontSize * 1.5, height: 2), + 'h3' => TextStyle(fontSize: fontSize * 1.4, height: 2), + 'h4' => TextStyle(fontSize: fontSize * 1.3, height: 1.75), + 'h5' => TextStyle(fontSize: fontSize * 1.2, height: 1.75), + 'h6' => TextStyle(fontSize: fontSize * 1.1, height: 1.5), + 'span' => TextStyle( + color: node.attributes['color']?.hexToColor ?? + node.attributes['data-mx-color']?.hexToColor ?? + textColor, + backgroundColor: node.attributes['data-mx-bg-color']?.hexToColor, + ), + 'sup' => const TextStyle(fontFeatures: [FontFeature.superscripts()]), + 'sub' => const TextStyle(fontFeatures: [FontFeature.subscripts()]), + _ => null, + }; + // Pangea# return TextSpan( style: switch (node.localName) { 'body' => TextStyle( @@ -663,6 +734,9 @@ class HtmlMessage extends StatelessWidget { children: _renderWithLineBreaks( node.nodes, context, + // #Pangea + textStyle.merge(style ?? const TextStyle()), + // Pangea# depth: depth, ), ); @@ -698,6 +772,12 @@ class HtmlMessage extends StatelessWidget { parsed, // Pangea# context, + // #Pangea + TextStyle( + fontSize: fontSize, + color: textColor, + ), + // Pangea# ), style: TextStyle( fontSize: fontSize, diff --git a/lib/pages/chat/events/message_content.dart b/lib/pages/chat/events/message_content.dart index 789255010..1d2bb18dc 100644 --- a/lib/pages/chat/events/message_content.dart +++ b/lib/pages/chat/events/message_content.dart @@ -11,6 +11,7 @@ import 'package:fluffychat/pages/chat/events/video_player.dart'; import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart'; import 'package:fluffychat/pangea/events/extensions/pangea_event_extension.dart'; import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; +import 'package:fluffychat/pangea/toolbar/controllers/tts_controller.dart'; import 'package:fluffychat/pangea/toolbar/enums/message_mode_enum.dart'; import 'package:fluffychat/pangea/toolbar/enums/reading_assistance_mode_enum.dart'; import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart'; @@ -142,7 +143,7 @@ class MessageContent extends StatelessWidget { const Duration( milliseconds: AppConfig.overlayAnimationDuration, ), () { - controller.choreographer.tts.tryToSpeak( + TtsController.tryToSpeak( token.text.content, langCode: pangeaMessageEvent!.messageDisplayLangCode, ); diff --git a/lib/pages/chat/events/reply_content.dart b/lib/pages/chat/events/reply_content.dart index 82584bafd..be8d67198 100644 --- a/lib/pages/chat/events/reply_content.dart +++ b/lib/pages/chat/events/reply_content.dart @@ -32,10 +32,16 @@ class ReplyContent extends StatelessWidget { timeline != null ? replyEvent.getDisplayEvent(timeline) : replyEvent; final fontSize = AppConfig.messageFontSize * AppConfig.fontSizeFactor; final color = theme.brightness == Brightness.dark - ? theme.colorScheme.onTertiaryContainer - : ownMessage + // Pangea# + ? ownMessage ? theme.colorScheme.tertiaryContainer - : theme.colorScheme.tertiary; + : theme.colorScheme.onTertiaryContainer + : theme.colorScheme.tertiary; + // ? theme.colorScheme.onTertiaryContainer + // : ownMessage + // ? theme.colorScheme.tertiaryContainer + // : theme.colorScheme.tertiary; + // Pangea# return Material( color: Colors.transparent, @@ -69,7 +75,9 @@ class ReplyContent extends StatelessWidget { fontWeight: FontWeight.bold, // #Pangea // color: color, - color: theme.colorScheme.onSurface, + color: ownMessage && theme.brightness == Brightness.dark + ? theme.colorScheme.tertiaryContainer + : theme.colorScheme.onSurface, // Pangea# fontSize: fontSize, ), diff --git a/lib/pages/chat_list/chat_list_body.dart b/lib/pages/chat_list/chat_list_body.dart index f6a47adf1..ec4280601 100644 --- a/lib/pages/chat_list/chat_list_body.dart +++ b/lib/pages/chat_list/chat_list_body.dart @@ -12,8 +12,8 @@ import 'package:fluffychat/pages/chat_list/dummy_chat_list_item.dart'; import 'package:fluffychat/pages/chat_list/search_title.dart'; import 'package:fluffychat/pages/chat_list/space_view.dart'; import 'package:fluffychat/pangea/chat_list/widgets/pangea_chat_list_header.dart'; +import 'package:fluffychat/pangea/public_spaces/public_room_bottom_sheet.dart'; import 'package:fluffychat/utils/stream_extension.dart'; -import 'package:fluffychat/widgets/adaptive_dialogs/public_room_dialog.dart'; import 'package:fluffychat/widgets/adaptive_dialogs/user_dialog.dart'; import 'package:fluffychat/widgets/avatar.dart'; import 'package:fluffychat/widgets/unread_rooms_badge.dart'; @@ -201,7 +201,8 @@ class ChatListViewBody extends StatelessWidget { // #Pangea // if (spaceDelegateCandidates.isNotEmpty && // !controller.widget.displayNavigationRail) - if (!controller.widget.displayNavigationRail) + if (!AppConfig.displayNavigationRail && + !FluffyThemes.isColumnMode(context)) // Pangea# ActiveFilter.spaces, ] @@ -384,7 +385,7 @@ class PublicRoomsHorizontalListState extends State { // Pangea# avatar: publicRooms[i].avatarUrl, // #Pangea - onPressed: () => PublicRoomDialog.show( + onPressed: () => PublicRoomBottomSheet.show( context: context, roomAlias: publicRooms[i].canonicalAlias ?? publicRooms[i].roomId, diff --git a/lib/pages/chat_list/chat_list_view.dart b/lib/pages/chat_list/chat_list_view.dart index 35d5c07dc..f69d52840 100644 --- a/lib/pages/chat_list/chat_list_view.dart +++ b/lib/pages/chat_list/chat_list_view.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:go_router/go_router.dart'; +import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/pages/chat_list/chat_list.dart'; import 'package:fluffychat/pangea/chat_list/widgets/chat_list_view_body_wrapper.dart'; @@ -31,8 +32,12 @@ class ChatListView extends StatelessWidget { }, child: Row( children: [ - if (FluffyThemes.isColumnMode(context) && - controller.widget.displayNavigationRail) ...[ + // #Pangea + // if (FluffyThemes.isColumnMode(context) && + // controller.widget.displayNavigationRail) ...[ + if (FluffyThemes.isColumnMode(context) || + AppConfig.displayNavigationRail) ...[ + // Pangea# SpacesNavigationRail( activeSpaceId: controller.activeSpaceId, onGoToChats: controller.clearActiveSpace, diff --git a/lib/pages/chat_list/navi_rail_item.dart b/lib/pages/chat_list/navi_rail_item.dart index 0e844e05d..6b4d3c540 100644 --- a/lib/pages/chat_list/navi_rail_item.dart +++ b/lib/pages/chat_list/navi_rail_item.dart @@ -44,7 +44,14 @@ class NaviRailItem extends StatelessWidget { bottom: 8, left: 0, child: AnimatedContainer( - width: isSelected ? 8 : 0, + // #Pangea + // width: isSelected ? 8 : 0, + width: isSelected + ? FluffyThemes.isColumnMode(context) + ? 8 + : 4 + : 0, + // Pangea# duration: FluffyThemes.animationDuration, curve: FluffyThemes.animationCurve, decoration: BoxDecoration( diff --git a/lib/pages/chat_list/space_view.dart b/lib/pages/chat_list/space_view.dart index 4471a01d8..879a57213 100644 --- a/lib/pages/chat_list/space_view.dart +++ b/lib/pages/chat_list/space_view.dart @@ -17,12 +17,12 @@ import 'package:fluffychat/pages/chat_list/search_title.dart'; import 'package:fluffychat/pangea/chat_settings/constants/pangea_room_types.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; +import 'package:fluffychat/pangea/public_spaces/public_room_bottom_sheet.dart'; import 'package:fluffychat/pangea/spaces/constants/space_constants.dart'; import 'package:fluffychat/pangea/spaces/widgets/knocking_users_indicator.dart'; import 'package:fluffychat/pangea/spaces/widgets/space_view_leaderboard.dart'; import 'package:fluffychat/utils/localized_exception_extension.dart'; import 'package:fluffychat/utils/stream_extension.dart'; -import 'package:fluffychat/widgets/adaptive_dialogs/public_room_dialog.dart'; import 'package:fluffychat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart'; import 'package:fluffychat/widgets/avatar.dart'; import 'package:fluffychat/widgets/future_loading_dialog.dart'; @@ -313,7 +313,7 @@ class _SpaceViewState extends State { // ?.via, // ), // ); - final joined = await PublicRoomDialog.show( + final joined = await PublicRoomBottomSheet.show( context: context, chunk: item, via: space?.spaceChildren @@ -527,6 +527,18 @@ class _SpaceViewState extends State { final room = Matrix.of(context).client.getRoomById(widget.spaceId); final displayname = room?.getLocalizedDisplayname() ?? L10n.of(context).nothingFound; + + // #Pangea + final joinedParents = room?.spaceParents + .map((parent) { + final roomId = parent.roomId; + if (roomId == null) return null; + return room.client.getRoomById(roomId); + }) + .whereType() + .toList(); + // Pangea# + return Scaffold( // #Pangea // appBar: AppBar( @@ -539,14 +551,51 @@ class _SpaceViewState extends State { _onSpaceAction(SpaceActions.settings); }, child: AppBar( - // Pangea# - leading: FluffyThemes.isColumnMode(context) - ? null + // 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: CloseButton( - onPressed: widget.onBack, - ), + child: joinedParents!.length == 1 + ? IconButton( + icon: const Icon(Icons.arrow_back_outlined), + onPressed: () => + widget.toParentSpace(joinedParents.first.id), + ) + : PopupMenuButton( + popUpAnimationStyle: AnimationStyle( + duration: const Duration(milliseconds: 0), + ), + 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( @@ -660,14 +709,16 @@ class _SpaceViewState extends State { // Pangea# .toList(); - final joinedParents = room.spaceParents - .map((parent) { - final roomId = parent.roomId; - if (roomId == null) return null; - return room.client.getRoomById(roomId); - }) - .whereType() - .toList(); + // #Pangea + // final joinedParents = room.spaceParents + // .map((parent) { + // final roomId = parent.roomId; + // if (roomId == null) return null; + // return room.client.getRoomById(roomId); + // }) + // .whereType() + // .toList(); + // Pangea# final filter = _filterController.text.trim().toLowerCase(); return CustomScrollView( slivers: [ @@ -715,51 +766,51 @@ class _SpaceViewState extends State { ), ), ), - SliverList.builder( - itemCount: joinedParents.length, - itemBuilder: (context, i) { - final displayname = - joinedParents[i].getLocalizedDisplayname(); - return Padding( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 1, - ), - child: Material( - borderRadius: - BorderRadius.circular(AppConfig.borderRadius), - clipBehavior: Clip.hardEdge, - child: ListTile( - minVerticalPadding: 0, - leading: Icon( - Icons.adaptive.arrow_back_outlined, - size: 16, - ), - title: Row( - children: [ - Avatar( - mxContent: joinedParents[i].avatar, - name: displayname, - // #Pangea - userId: joinedParents[i].directChatMatrixID, - // Pangea# - size: Avatar.defaultSize / 2, - borderRadius: BorderRadius.circular( - AppConfig.borderRadius / 4, - ), - ), - const SizedBox(width: 8), - Expanded(child: Text(displayname)), - ], - ), - onTap: () => - widget.toParentSpace(joinedParents[i].id), - ), - ), - ); - }, - ), // #Pangea + // SliverList.builder( + // itemCount: joinedParents.length, + // itemBuilder: (context, i) { + // final displayname = + // joinedParents[i].getLocalizedDisplayname(); + // return Padding( + // padding: const EdgeInsets.symmetric( + // horizontal: 8, + // vertical: 1, + // ), + // child: Material( + // borderRadius: + // BorderRadius.circular(AppConfig.borderRadius), + // clipBehavior: Clip.hardEdge, + // child: ListTile( + // minVerticalPadding: 0, + // leading: Icon( + // Icons.adaptive.arrow_back_outlined, + // size: 16, + // ), + // title: Row( + // children: [ + // Avatar( + // mxContent: joinedParents[i].avatar, + // name: displayname, + // // #Pangea + // userId: joinedParents[i].directChatMatrixID, + // // Pangea# + // size: Avatar.defaultSize / 2, + // borderRadius: BorderRadius.circular( + // AppConfig.borderRadius / 4, + // ), + // ), + // const SizedBox(width: 8), + // Expanded(child: Text(displayname)), + // ], + // ), + // onTap: () => + // widget.toParentSpace(joinedParents[i].id), + // ), + // ), + // ); + // }, + // ), KnockingUsersIndicator(room: room), // Pangea# SliverList.builder( 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 e0f77ffaa..f4e25d9f9 100644 --- a/lib/pages/chat_permissions_settings/chat_permissions_settings_view.dart +++ b/lib/pages/chat_permissions_settings/chat_permissions_settings_view.dart @@ -20,7 +20,10 @@ class ChatPermissionsSettingsView extends StatelessWidget { return Scaffold( appBar: AppBar( leading: const Center(child: BackButton()), - title: Text(L10n.of(context).chatPermissions), + // #Pangea + // title: Text(L10n.of(context).chatPermissions), + title: Text(L10n.of(context).permissions), + // Pangea# ), body: MaxWidthBody( child: StreamBuilder( @@ -36,28 +39,11 @@ class ChatPermissionsSettingsView extends StatelessWidget { final powerLevelsContent = Map.from( room.getState(EventTypes.RoomPowerLevels)?.content ?? {}, ); - final powerLevels = - Map.from(powerLevelsContent) // #Pangea - // ..removeWhere((k, v) => v is! int); - ..removeWhere( - (k, v) => - v is! int || - k.equals("m.call.invite") || - k.equals("historical") || - k.equals("state_default"), - ); - // Pangea# + final powerLevels = Map.from(powerLevelsContent) + ..removeWhere((k, v) => v is! int); final eventsPowerLevels = Map.from( powerLevelsContent.tryGetMap('events') ?? {}, - // #Pangea - )..removeWhere( - (k, v) => - v is! int || - k.equals("pangea.usranalytics") || - k.equals(EventTypes.RoomPowerLevels), - ); - // )..removeWhere((k, v) => v is! int); - // Pangea# + )..removeWhere((k, v) => v is! int); return Column( children: [ ListTile( @@ -69,7 +55,10 @@ class ChatPermissionsSettingsView extends StatelessWidget { Divider(color: theme.dividerColor), ListTile( title: Text( - L10n.of(context).chatPermissions, + // #Pangea + // L10n.of(context).chatPermissions, + L10n.of(context).permissions, + // Pangea# style: TextStyle( color: theme.colorScheme.primary, fontWeight: FontWeight.bold, @@ -90,48 +79,57 @@ class ChatPermissionsSettingsView extends StatelessWidget { newLevel: level, ), canEdit: room.canChangePowerLevel, + // #Pangea + room: room, + // Pangea# ), - // #Pangea - // Divider(color: theme.dividerColor), - // ListTile( - // title: Text( - // L10n.of(context).notifications, - // style: TextStyle( - // color: theme.colorScheme.primary, - // fontWeight: FontWeight.bold, - // ), - // ), - // ), - // Builder( - // builder: (context) { - // const key = 'rooms'; - // final value = powerLevelsContent - // .containsKey('notifications') - // ? powerLevelsContent - // .tryGetMap('notifications') - // ?.tryGet('rooms') ?? - // 0 - // : 0; - // return PermissionsListTile( - // permissionKey: key, - // permission: value, - // category: 'notifications', - // canEdit: room.canChangePowerLevel, - // onChanged: (level) => controller.editPowerLevel( - // context, - // key, - // value, - // newLevel: level, - // category: 'notifications', - // ), - // ); - // }, - // ), - // Pangea# Divider(color: theme.dividerColor), ListTile( title: Text( - L10n.of(context).configureChat, + L10n.of(context).notifications, + style: TextStyle( + color: theme.colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + ), + Builder( + builder: (context) { + const key = 'rooms'; + final value = powerLevelsContent + .containsKey('notifications') + ? powerLevelsContent + .tryGetMap('notifications') + ?.tryGet('rooms') ?? + 0 + : 0; + return PermissionsListTile( + permissionKey: key, + permission: value, + category: 'notifications', + canEdit: room.canChangePowerLevel, + onChanged: (level) => controller.editPowerLevel( + context, + key, + value, + newLevel: level, + category: 'notifications', + ), + // #Pangea + room: room, + // Pangea# + ); + }, + ), + Divider(color: theme.dividerColor), + ListTile( + title: Text( + // #Pangea + // L10n.of(context).configureChat, + room.isSpace + ? L10n.of(context).configureSpace + : L10n.of(context).configureChat, + // Pangea# style: TextStyle( color: theme.colorScheme.primary, fontWeight: FontWeight.bold, @@ -151,6 +149,9 @@ class ChatPermissionsSettingsView extends StatelessWidget { newLevel: level, category: 'events', ), + // #Pangea + room: room, + // Pangea# ), ], ), diff --git a/lib/pages/chat_permissions_settings/permission_list_tile.dart b/lib/pages/chat_permissions_settings/permission_list_tile.dart index 704bdc696..a8f14e002 100644 --- a/lib/pages/chat_permissions_settings/permission_list_tile.dart +++ b/lib/pages/chat_permissions_settings/permission_list_tile.dart @@ -11,6 +11,9 @@ class PermissionsListTile extends StatelessWidget { final String? category; final void Function(int? level)? onChanged; final bool canEdit; + // #Pangea + final Room room; + // Pangea# const PermissionsListTile({ super.key, @@ -19,6 +22,9 @@ class PermissionsListTile extends StatelessWidget { this.category, required this.onChanged, required this.canEdit, + // #Pangea + required this.room, + // Pangea# }); String getLocalizedPowerLevelString(BuildContext context) { @@ -29,15 +35,27 @@ class PermissionsListTile extends StatelessWidget { case 'events_default': return L10n.of(context).sendMessages; case 'state_default': - return L10n.of(context).changeGeneralChatSettings; + // #Pangea + // return L10n.of(context).changeGeneralChatSettings; + return L10n.of(context).changeGeneralSettings; + // Pangea# case 'ban': - return L10n.of(context).banFromChat; + // #Pangea + // return L10n.of(context).banFromChat; + return L10n.of(context).ban; + // Pangea# case 'kick': - return L10n.of(context).kickFromChat; + // #Pangea + // return L10n.of(context).kickFromChat; + return L10n.of(context).kick; + // Pangea# case 'redact': return L10n.of(context).deleteMessage; case 'invite': - return L10n.of(context).inviteOtherUsers; + // #Pangea + // return L10n.of(context).inviteOtherUsers; + return L10n.of(context).inviteOtherUsersToRoom; + // Pangea# } } else if (category == 'notifications') { switch (permissionKey) { @@ -49,12 +67,20 @@ class PermissionsListTile extends StatelessWidget { case EventTypes.RoomName: // #Pangea // return L10n.of(context).changeTheNameOfTheGroup; - return L10n.of(context).changeTheNameOfTheChat; + return room.isSpace + ? L10n.of(context).changeTheNameOfTheSpace + : L10n.of(context).changeTheNameOfTheChat; // Pangea# case EventTypes.RoomTopic: - return L10n.of(context).changeTheDescriptionOfTheGroup; + // #Pangea + // return L10n.of(context).changeTheDescriptionOfTheGroup; + return L10n.of(context).changeTheDescription; + // Pangea# case EventTypes.RoomPowerLevels: - return L10n.of(context).changeTheChatPermissions; + // #Pangea + // return L10n.of(context).changeTheChatPermissions; + return L10n.of(context).changeThePermissions; + // Pangea# case EventTypes.HistoryVisibility: return L10n.of(context).changeTheVisibilityOfChatHistory; case EventTypes.RoomCanonicalAlias: @@ -70,6 +96,10 @@ class PermissionsListTile extends StatelessWidget { // #Pangea case EventTypes.SpaceChild: return L10n.of(context).spaceChildPermission; + case EventTypes.RoomPinnedEvents: + return L10n.of(context).pinMessages; + case EventTypes.RoomJoinRules: + return L10n.of(context).setJoinRules; // Pangea# } } diff --git a/lib/pages/new_group/new_group.dart b/lib/pages/new_group/new_group.dart index 4e9d4cc57..b2146b55f 100644 --- a/lib/pages/new_group/new_group.dart +++ b/lib/pages/new_group/new_group.dart @@ -7,7 +7,6 @@ import 'package:go_router/go_router.dart'; import 'package:matrix/matrix.dart' as sdk; import 'package:matrix/matrix.dart'; -import 'package:fluffychat/config/app_config.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/chat/constants/default_power_level.dart'; @@ -48,6 +47,8 @@ class NewGroupController extends State { bool requiredCodeToJoin = false; // bool publicGroup = false; + + bool get canSubmit => nameController.text.trim().isNotEmpty; // Pangea# bool groupCanBeFound = false; @@ -186,10 +187,8 @@ class NewGroupController extends State { ); } } - // if a timeout happened, don't redirect to the chat - if (error != null) return; - // Pangea# context.go('/rooms/$roomId/invite?filter=groups'); + // Pangea# } Future _createSpace() async { @@ -218,6 +217,8 @@ class NewGroupController extends State { // context.pop(spaceId); final spaceId = await Matrix.of(context).client.createPangeaSpace( name: nameController.text, + introChatName: L10n.of(context).introductions, + announcementsChatName: L10n.of(context).announcements, visibility: groupCanBeFound ? sdk.Visibility.public : sdk.Visibility.private, joinRules: @@ -235,8 +236,6 @@ class NewGroupController extends State { GoogleAnalytics.createClass(room.name, spaceCode); } - // if a timeout happened, don't redirect to the space - if (error != null) return; context.go("/rooms?spaceId=$spaceId"); // Pangea# } @@ -250,10 +249,11 @@ class NewGroupController extends State { focusNode.requestFocus(); return; } - // Pangea# - if (nameController.text.trim().isEmpty && - createGroupType == CreateGroupType.space) { + // if (nameController.text.trim().isEmpty && + // createGroupType == CreateGroupType.space) { + if (!canSubmit) { + // Pangea# setState(() => error = L10n.of(context).pleaseFillOut); return; } @@ -270,23 +270,9 @@ class NewGroupController extends State { switch (createGroupType) { case CreateGroupType.group: - // #Pangea - // await _createGroup(); - await _createGroup().timeout( - const Duration( - seconds: AppConfig.roomCreationTimeoutSeconds, - ), - ); - // Pangea# + await _createGroup(); case CreateGroupType.space: - // #Pangea - // await _createSpace(); - await _createSpace().timeout( - const Duration( - seconds: AppConfig.roomCreationTimeoutSeconds, - ), - ); - // Pangea# + await _createSpace(); } } catch (e, s) { sdk.Logs().d('Unable to create group', e, s); diff --git a/lib/pages/new_group/new_group_view.dart b/lib/pages/new_group/new_group_view.dart index fc99a5225..6bbae6fe4 100644 --- a/lib/pages/new_group/new_group_view.dart +++ b/lib/pages/new_group/new_group_view.dart @@ -131,9 +131,9 @@ class NewGroupView extends StatelessWidget { onFieldSubmitted: (value) { controller.loading ? null : controller.submitAction(); }, - validator: (value) => value == null || value.isEmpty - ? L10n.of(context).pleaseFillOut - : null, + validator: (value) => controller.canSubmit + ? null + : L10n.of(context).pleaseFillOut, focusNode: controller.focusNode, // Pangea# ), diff --git a/lib/pages/settings/settings_view.dart b/lib/pages/settings/settings_view.dart index a912fd401..4c51ea86d 100644 --- a/lib/pages/settings/settings_view.dart +++ b/lib/pages/settings/settings_view.dart @@ -41,7 +41,11 @@ class SettingsView extends StatelessWidget { // Pangea# return Row( children: [ - if (FluffyThemes.isColumnMode(context)) ...[ + // #Pangea + // if (FluffyThemes.isColumnMode(context)) ...[ + if (FluffyThemes.isColumnMode(context) || + AppConfig.displayNavigationRail) ...[ + // Pangea# SpacesNavigationRail( activeSpaceId: null, onGoToChats: () => context.go('/rooms'), diff --git a/lib/pages/settings_style/settings_style_view.dart b/lib/pages/settings_style/settings_style_view.dart index d234759df..05bde2f42 100644 --- a/lib/pages/settings_style/settings_style_view.dart +++ b/lib/pages/settings_style/settings_style_view.dart @@ -359,6 +359,14 @@ class SettingsStyleView extends StatelessWidget { storeKey: SettingKeys.separateChatTypes, defaultValue: AppConfig.separateChatTypes, ), + // #Pangea + // SettingsSwitchListTile.adaptive( + // title: L10n.of(context).displayNavigationRail, + // onChanged: (b) => AppConfig.displayNavigationRail = b, + // storeKey: SettingKeys.displayNavigationRail, + // defaultValue: AppConfig.displayNavigationRail, + // ), + // Pangea# ], ), ), diff --git a/lib/pangea/activity_generator/activity_generator_view.dart b/lib/pangea/activity_generator/activity_generator_view.dart index c197e6f88..b86c4fe91 100644 --- a/lib/pangea/activity_generator/activity_generator_view.dart +++ b/lib/pangea/activity_generator/activity_generator_view.dart @@ -7,6 +7,7 @@ import 'package:material_symbols_icons/symbols.dart'; import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/pangea/activity_generator/activity_generator.dart'; import 'package:fluffychat/pangea/activity_planner/activity_plan_card.dart'; +import 'package:fluffychat/pangea/activity_planner/activity_planner_builder.dart'; import 'package:fluffychat/pangea/activity_planner/suggestion_form_field.dart'; import 'package:fluffychat/pangea/activity_suggestions/activity_suggestions_constants.dart'; import 'package:fluffychat/pangea/instructions/instructions_enum.dart'; @@ -53,13 +54,15 @@ class ActivityGeneratorView extends StatelessWidget { padding: const EdgeInsets.all(16), itemCount: controller.activities!.length, itemBuilder: (context, index) { - return ActivityPlanCard( - activity: controller.activities![index], + return ActivityPlannerBuilder( + initialActivity: controller.activities![index], + initialFilename: controller.filename, room: controller.room, - onEdit: (updatedActivity) => - controller.onEdit(index, updatedActivity), - onChange: controller.update, - initialImageURL: controller.filename, + builder: (c) { + return ActivityPlanCard( + controller: c, + ); + }, ); }, ); diff --git a/lib/pangea/activity_planner/activity_plan_card.dart b/lib/pangea/activity_planner/activity_plan_card.dart index 0d721e11e..cc1e3b1a4 100644 --- a/lib/pangea/activity_planner/activity_plan_card.dart +++ b/lib/pangea/activity_planner/activity_plan_card.dart @@ -6,37 +6,23 @@ import 'package:flutter/material.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:go_router/go_router.dart'; -import 'package:http/http.dart' as http; import 'package:material_symbols_icons/symbols.dart'; -import 'package:matrix/matrix.dart' as sdk; -import 'package:matrix/matrix.dart'; import 'package:fluffychat/config/themes.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/chat/constants/default_power_level.dart'; +import 'package:fluffychat/pangea/activity_suggestions/activity_room_selection.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; -import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; -import 'package:fluffychat/utils/file_selector.dart'; +import 'package:fluffychat/pangea/common/widgets/full_width_dialog.dart'; import 'package:fluffychat/widgets/future_loading_dialog.dart'; -import 'package:fluffychat/widgets/matrix.dart'; class ActivityPlanCard extends StatefulWidget { - final ActivityPlanModel activity; - final Room? room; - final VoidCallback onChange; - final ValueChanged onEdit; - final double maxWidth; - final String? initialImageURL; + final ActivityPlannerBuilderState controller; const ActivityPlanCard({ super.key, - required this.activity, - required this.room, - required this.onChange, - required this.onEdit, - this.maxWidth = 400, - this.initialImageURL, + required this.controller, }); @override @@ -44,59 +30,8 @@ class ActivityPlanCard extends StatefulWidget { } class ActivityPlanCardState extends State { - bool _isEditing = false; - late ActivityPlanModel _tempActivity; - late TextEditingController _titleController; - late TextEditingController _learningObjectiveController; - late TextEditingController _instructionsController; - final TextEditingController _newVocabController = TextEditingController(); - final FocusNode _vocabFocusNode = FocusNode(); - - Uint8List? _avatar; - String? _filename; - String? _imageURL; - - @override - void initState() { - super.initState(); - _tempActivity = widget.activity; - _titleController = TextEditingController(text: _tempActivity.title); - _learningObjectiveController = - TextEditingController(text: _tempActivity.learningObjective); - _instructionsController = - TextEditingController(text: _tempActivity.instructions); - _filename = widget.initialImageURL?.split("/").last; - _imageURL = widget.activity.imageURL ?? widget.initialImageURL; - } - static const double itemPadding = 12; - @override - void dispose() { - _titleController.dispose(); - _learningObjectiveController.dispose(); - _instructionsController.dispose(); - _newVocabController.dispose(); - _vocabFocusNode.dispose(); - super.dispose(); - } - - Future _saveEdits() async { - final updatedActivity = ActivityPlanModel( - req: _tempActivity.req, - title: _titleController.text, - learningObjective: _learningObjectiveController.text, - instructions: _instructionsController.text, - vocab: _tempActivity.vocab, - imageURL: widget.activity.imageURL, - ); - - widget.onEdit(updatedActivity); - setState(() { - _isEditing = false; - }); - } - Future _addBookmark(ActivityPlanModel activity) async { try { return BookmarkedActivitiesRepo.save(activity); @@ -107,418 +42,350 @@ class ActivityPlanCardState extends State { } finally { if (mounted) { setState(() {}); - widget.onChange(); } } } Future _removeBookmark() async { try { - BookmarkedActivitiesRepo.remove(widget.activity.bookmarkId); + BookmarkedActivitiesRepo.remove( + widget.controller.updatedActivity.bookmarkId, + ); } catch (e, stack) { debugger(when: kDebugMode); - ErrorHandler.logError(e: e, s: stack, data: widget.activity.toJson()); + ErrorHandler.logError( + e: e, + s: stack, + data: widget.controller.updatedActivity.toJson(), + ); } finally { if (mounted) { setState(() {}); - widget.onChange(); } } } - void _addVocab() { - setState(() { - _tempActivity.vocab.add(Vocab(lemma: _newVocabController.text, pos: '')); - _newVocabController.clear(); - _vocabFocusNode.requestFocus(); - }); - } - - void _removeVocab(int index) { - setState(() { - _tempActivity.vocab.removeAt(index); - }); - } - - void selectPhoto() async { - final resp = await selectFiles( - context, - type: FileSelectorType.images, - allowMultiple: false, - ); - - final photo = resp.singleOrNull; - if (photo == null) return; - final bytes = await photo.readAsBytes(); - - setState(() { - _avatar = bytes; - _filename = photo.name; - }); - - final url = await Matrix.of(context).client.uploadContent( - bytes, - filename: photo.name, - ); - - final updatedActivity = ActivityPlanModel( - req: _tempActivity.req, - title: _tempActivity.title, - learningObjective: _tempActivity.learningObjective, - instructions: _tempActivity.instructions, - vocab: _tempActivity.vocab, - imageURL: url.toString(), - ); - - widget.onEdit(updatedActivity); - } - - Future _setAvatarByImageURL() async { - if (_avatar != null || _imageURL == null) return; - final resp = await http - .get(Uri.parse(_imageURL!)) - .timeout(const Duration(seconds: 5)); - if (mounted) { - setState(() => _avatar = resp.bodyBytes); - } - } - Future _onLaunch() async { - await _setAvatarByImageURL(); - await showFutureLoadingDialog( + if (widget.controller.room != null) { + final resp = await showFutureLoadingDialog( + context: context, + future: widget.controller.launchToRoom, + ); + if (!resp.isError) { + context.go("/rooms/${widget.controller.room!.id}"); + } + return; + } + + return showDialog( context: context, - future: () async { - String? avatarUrl; - if (_avatar != null) { - final client = Matrix.of(context).client; - final url = await client.uploadContent( - _avatar!, - filename: _filename, - ); - avatarUrl = url.toString(); - } - - if (widget.room != null) { - await widget.room?.sendActivityPlan( - widget.activity, - avatar: _avatar, - filename: _filename, - ); - - context.go("/rooms/${widget.room?.id}"); - return; - } - - final client = Matrix.of(context).client; - final roomId = await client.createGroupChat( - preset: CreateRoomPreset.publicChat, - visibility: sdk.Visibility.private, - groupName: - widget.activity.title.isNotEmpty ? widget.activity.title : null, - initialState: [ - if (_avatar != null) ...[ - StateEvent( - type: EventTypes.RoomAvatar, - stateKey: '', - content: { - "url": avatarUrl, - }, - ), - ], - StateEvent( - type: EventTypes.RoomPowerLevels, - stateKey: '', - content: defaultPowerLevels(client.userID!), + builder: (context) { + return FullWidthDialog( + dialogContent: DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, ), - ], - enableEncryption: false, + child: ActivityRoomSelection( + controller: widget.controller, + backButton: IconButton( + onPressed: Navigator.of(context).pop, + icon: const Icon(Icons.close), + ), + ), + ), + maxWidth: 400.0, + maxHeight: 650.0, ); - - Room? room = client.getRoomById(roomId); - if (room == null) { - await client.waitForRoomInSync(roomId); - room = client.getRoomById(roomId); - } - if (room == null) return; - - await room.sendActivityPlan( - widget.activity, - avatar: _avatar, - filename: _filename, - ); - - context.go("/rooms/$roomId/invite?filter=groups"); }, ); } - bool get isBookmarked => - BookmarkedActivitiesRepo.isBookmarked(widget.activity); + bool get _isBookmarked => BookmarkedActivitiesRepo.isBookmarked( + widget.controller.updatedActivity, + ); @override Widget build(BuildContext context) { final l10n = L10n.of(context); return Center( child: ConstrainedBox( - constraints: BoxConstraints(maxWidth: widget.maxWidth), + constraints: const BoxConstraints(maxWidth: 400), child: Card( margin: const EdgeInsets.symmetric(vertical: itemPadding), - child: Column( - children: [ - AnimatedSize( - duration: FluffyThemes.animationDuration, - child: Stack( - children: [ - Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12.0), + child: Form( + key: widget.controller.formKey, + child: Column( + children: [ + AnimatedSize( + duration: FluffyThemes.animationDuration, + child: Stack( + children: [ + Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12.0), + ), + clipBehavior: Clip.hardEdge, + alignment: Alignment.center, + child: widget.controller.imageURL != null || + widget.controller.avatar != null + ? ClipRRect( + 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), + ), ), - clipBehavior: Clip.hardEdge, - alignment: Alignment.center, - child: _imageURL != null || _avatar != null - ? ClipRRect( - child: _avatar == null - ? CachedNetworkImage( - fit: BoxFit.cover, - imageUrl: _imageURL!, - placeholder: (context, url) { - return const Center( - child: CircularProgressIndicator(), - ); - }, - errorWidget: (context, url, error) { - return const Padding( - padding: EdgeInsets.all(28.0), - ); - }, - ) - : Image.memory( - _avatar!, - fit: BoxFit.cover, - ), - ) - : const Padding( - padding: EdgeInsets.all(28.0), + if (widget.controller.isEditing) + Positioned( + top: 10.0, + right: 10.0, + child: IconButton( + icon: const Icon(Icons.upload_outlined), + onPressed: widget.controller.selectAvatar, + style: IconButton.styleFrom( + backgroundColor: Colors.black, ), - ), - if (_isEditing) - Positioned( - top: 10.0, - right: 10.0, - child: IconButton( - icon: const Icon(Icons.upload_outlined), - onPressed: selectPhoto, - style: IconButton.styleFrom( - backgroundColor: Colors.black, ), ), - ), - ], + ], + ), ), - ), - Padding( - 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: _isEditing - ? TextField( - controller: _titleController, - decoration: InputDecoration( - labelText: L10n.of(context).activityTitle, + Padding( + 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, + ), + maxLines: null, + ) + : Text( + widget.controller.updatedActivity.title, + style: + Theme.of(context).textTheme.bodyLarge, ), - maxLines: null, - ) - : Text( - widget.activity.title, - style: Theme.of(context).textTheme.bodyLarge, - ), - ), - if (!_isEditing) - IconButton( - onPressed: isBookmarked - ? () => _removeBookmark() - : () => _addBookmark(widget.activity), - icon: Icon( - isBookmarked - ? Icons.bookmark - : Icons.bookmark_border, - ), ), - ], - ), - const SizedBox(height: itemPadding), - Row( - children: [ - Icon( - Symbols.target, - color: Theme.of(context).colorScheme.secondary, - ), - const SizedBox(width: itemPadding), - Expanded( - child: _isEditing - ? TextField( - controller: _learningObjectiveController, - decoration: InputDecoration( - labelText: l10n.learningObjectiveLabel, - ), - maxLines: null, - ) - : Text( - widget.activity.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: _isEditing - ? TextField( - controller: _instructionsController, - decoration: InputDecoration( - labelText: l10n.instructions, - ), - maxLines: null, - ) - : Text( - widget.activity.instructions, - style: Theme.of(context).textTheme.bodyMedium, - ), - ), - ], - ), - const SizedBox(height: itemPadding), - if (widget.activity.vocab.isNotEmpty) ...[ + if (!widget.controller.isEditing) + IconButton( + onPressed: _isBookmarked + ? () => _removeBookmark() + : () => _addBookmark( + widget.controller.updatedActivity, + ), + icon: Icon( + _isBookmarked + ? Icons.bookmark + : Icons.bookmark_border, + ), + ), + ], + ), + const SizedBox(height: itemPadding), Row( children: [ Icon( - Symbols.dictionary, + Symbols.target, color: Theme.of(context).colorScheme.secondary, ), const SizedBox(width: itemPadding), Expanded( - child: Wrap( - spacing: 4.0, - runSpacing: 4.0, - children: List.generate( - _tempActivity.vocab.length, (int index) { - return _isEditing - ? Chip( - label: Text( - _tempActivity.vocab[index].lemma, - ), - onDeleted: () => _removeVocab(index), - backgroundColor: Colors.transparent, - visualDensity: VisualDensity.compact, - shape: const StadiumBorder( - side: BorderSide( - color: Colors.transparent, - ), - ), - ) - : Chip( - label: Text( - _tempActivity.vocab[index].lemma, - ), - backgroundColor: Colors.transparent, - visualDensity: VisualDensity.compact, - shape: const StadiumBorder( - side: BorderSide( - color: Colors.transparent, - ), - ), - ); - }).toList(), + 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), + 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(); + }, + ), + ), + IconButton( + icon: const Icon(Icons.add), + onPressed: widget.controller.addVocab, + ), + ], + ), + ), + ], + const SizedBox(height: itemPadding), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Tooltip( + message: !widget.controller.isEditing + ? l10n.edit + : l10n.saveChanges, + child: IconButton( + icon: Icon( + !widget.controller.isEditing + ? Icons.edit + : Icons.save, + ), + onPressed: () => !widget.controller.isEditing + ? setState(() { + widget.controller.isEditing = true; + }) + : widget.controller.saveEdits(), + isSelected: widget.controller.isEditing, + ), + ), + if (widget.controller.isEditing) + Tooltip( + message: l10n.cancel, + child: IconButton( + icon: const Icon(Icons.cancel), + onPressed: widget.controller.clearEdits, + ), + ), + ], + ), + ElevatedButton.icon( + onPressed: + !widget.controller.isEditing ? _onLaunch : null, + icon: const Icon(Icons.send), + label: Text(l10n.launchActivityButton), ), ], ), ], - if (_isEditing) ...[ - const SizedBox(height: itemPadding), - Padding( - padding: const EdgeInsets.only(top: itemPadding), - child: Row( - children: [ - Expanded( - child: TextField( - controller: _newVocabController, - focusNode: _vocabFocusNode, - decoration: InputDecoration( - labelText: l10n.addVocabulary, - ), - onSubmitted: (value) { - _addVocab(); - }, - ), - ), - IconButton( - icon: const Icon(Icons.add), - onPressed: _addVocab, - ), - ], - ), - ), - ], - const SizedBox(height: itemPadding), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - Tooltip( - message: - !_isEditing ? l10n.edit : l10n.saveChanges, - child: IconButton( - icon: - Icon(!_isEditing ? Icons.edit : Icons.save), - onPressed: () => !_isEditing - ? setState(() { - _isEditing = true; - }) - : _saveEdits(), - isSelected: _isEditing, - ), - ), - if (_isEditing) - Tooltip( - message: l10n.cancel, - child: IconButton( - icon: const Icon(Icons.cancel), - onPressed: () { - setState(() { - _isEditing = false; - }); - }, - ), - ), - ], - ), - ElevatedButton.icon( - onPressed: !_isEditing ? _onLaunch : null, - icon: const Icon(Icons.send), - label: Text(l10n.launchActivityButton), - ), - ], - ), - ], + ), ), - ), - ], + ], + ), ), ), ), diff --git a/lib/pangea/activity_planner/activity_plan_message.dart b/lib/pangea/activity_planner/activity_plan_message.dart index d4331c4eb..ae8b31471 100644 --- a/lib/pangea/activity_planner/activity_plan_message.dart +++ b/lib/pangea/activity_planner/activity_plan_message.dart @@ -130,13 +130,6 @@ class ActivityPlanMessage extends StatelessWidget { AppConfig.borderRadius, ), ), - padding: - event.messageType == MessageTypes.Image - ? EdgeInsets.zero - : const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, - ), constraints: const BoxConstraints( maxWidth: FluffyThemes.columnWidth * 1.5, ), diff --git a/lib/pangea/activity_planner/activity_planner_builder.dart b/lib/pangea/activity_planner/activity_planner_builder.dart new file mode 100644 index 000000000..36e72c0c9 --- /dev/null +++ b/lib/pangea/activity_planner/activity_planner_builder.dart @@ -0,0 +1,233 @@ +import 'dart:typed_data'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import 'package:collection/collection.dart'; +import 'package:http/http.dart' as http; +import 'package:http/http.dart'; +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart'; +import 'package:fluffychat/pangea/common/utils/error_handler.dart'; +import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; +import 'package:fluffychat/utils/client_download_content_extension.dart'; +import 'package:fluffychat/utils/file_selector.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +class ActivityPlannerBuilder extends StatefulWidget { + final ActivityPlanModel initialActivity; + final String? initialFilename; + final Room? room; + + final Widget Function(ActivityPlannerBuilderState) builder; + + final Future Function( + String, + ActivityPlanModel, + Uint8List?, + String?, + )? onEdit; + + const ActivityPlannerBuilder({ + super.key, + required this.initialActivity, + this.initialFilename, + this.room, + required this.builder, + this.onEdit, + }); + + @override + State createState() => ActivityPlannerBuilderState(); +} + +class ActivityPlannerBuilderState extends State { + bool isEditing = false; + Uint8List? avatar; + String? imageURL; + String? filename; + + final TextEditingController titleController = TextEditingController(); + final TextEditingController instructionsController = TextEditingController(); + final TextEditingController vocabController = TextEditingController(); + final TextEditingController participantsController = TextEditingController(); + final TextEditingController learningObjectivesController = + TextEditingController(); + + final List vocab = []; + + final GlobalKey formKey = GlobalKey(); + + @override + void initState() { + super.initState(); + _resetActivity(); + } + + @override + void dispose() { + titleController.dispose(); + learningObjectivesController.dispose(); + instructionsController.dispose(); + vocabController.dispose(); + participantsController.dispose(); + super.dispose(); + } + + Room? get room => widget.room; + + ActivityPlanModel get updatedActivity { + final int participants = int.tryParse(participantsController.text.trim()) ?? + widget.initialActivity.req.numberOfParticipants; + + final updatedReq = widget.initialActivity.req; + updatedReq.numberOfParticipants = participants; + + return ActivityPlanModel( + req: updatedReq, + title: titleController.text, + learningObjective: learningObjectivesController.text, + instructions: instructionsController.text, + vocab: vocab, + imageURL: imageURL, + ); + } + + Future _resetActivity() async { + avatar = null; + filename = null; + imageURL = null; + + titleController.text = widget.initialActivity.title; + learningObjectivesController.text = + widget.initialActivity.learningObjective; + instructionsController.text = widget.initialActivity.instructions; + participantsController.text = + widget.initialActivity.req.numberOfParticipants.toString(); + + vocab.clear(); + vocab.addAll(widget.initialActivity.vocab); + + imageURL = widget.initialActivity.imageURL; + filename = widget.initialFilename; + await _setAvatarByURL(); + if (mounted) setState(() {}); + } + + void setEditing(bool editting) { + isEditing = editting; + if (mounted) setState(() {}); + } + + void addVocab() { + vocab.insert( + 0, + Vocab( + lemma: vocabController.text.trim(), + pos: "", + ), + ); + vocabController.clear(); + if (mounted) setState(() {}); + } + + void removeVocab(int index) { + vocab.removeAt(index); + if (mounted) setState(() {}); + } + + void selectAvatar() async { + final photo = await selectFiles( + context, + type: FileSelectorType.images, + allowMultiple: false, + ); + final bytes = await photo.singleOrNull?.readAsBytes(); + if (mounted) { + setState(() { + avatar = bytes; + filename = photo.singleOrNull?.name; + }); + } + } + + Future _setAvatarByURL() async { + if (widget.initialActivity.imageURL == null) return; + try { + if (avatar == null) { + if (widget.initialActivity.imageURL!.startsWith("mxc")) { + final client = Matrix.of(context).client; + final mxcUri = Uri.parse(widget.initialActivity.imageURL!); + final data = await client.downloadMxcCached(mxcUri); + avatar = data; + filename = Uri.encodeComponent( + mxcUri.pathSegments.last, + ); + } else { + final Response response = + await http.get(Uri.parse(widget.initialActivity.imageURL!)); + avatar = response.bodyBytes; + filename = Uri.encodeComponent( + Uri.parse(widget.initialActivity.imageURL!).pathSegments.last, + ); + } + } + } catch (err, s) { + ErrorHandler.logError( + e: err, + s: s, + data: { + "imageURL": widget.initialActivity.imageURL, + }, + ); + } + } + + Future updateImageURL() async { + if (avatar == null) return; + final url = await Matrix.of(context).client.uploadContent( + avatar!, + filename: filename, + ); + if (!mounted) return; + setState(() { + imageURL = url.toString(); + }); + } + + Future saveEdits() async { + if (!formKey.currentState!.validate()) return; + await updateImageURL(); + setEditing(false); + if (widget.onEdit != null) { + await widget.onEdit!( + widget.initialActivity.bookmarkId, + updatedActivity, + avatar, + filename, + ); + } + } + + Future clearEdits() async { + await _resetActivity(); + if (mounted) { + setState(() { + isEditing = false; + }); + } + } + + Future launchToRoom() async { + return widget.room?.sendActivityPlan( + updatedActivity, + avatar: avatar, + filename: filename, + avatarURL: imageURL, + ); + } + + @override + Widget build(BuildContext context) => widget.builder(this); +} diff --git a/lib/pangea/activity_planner/activity_planner_page.dart b/lib/pangea/activity_planner/activity_planner_page.dart index e9caefe00..f5316e56a 100644 --- a/lib/pangea/activity_planner/activity_planner_page.dart +++ b/lib/pangea/activity_planner/activity_planner_page.dart @@ -14,8 +14,8 @@ enum PageMode { } class ActivityPlannerPage extends StatefulWidget { - final String roomID; - const ActivityPlannerPage({super.key, required this.roomID}); + final String? roomID; + const ActivityPlannerPage({super.key, this.roomID}); @override ActivityPlannerPageState createState() => ActivityPlannerPageState(); @@ -23,7 +23,9 @@ class ActivityPlannerPage extends StatefulWidget { class ActivityPlannerPageState extends State { PageMode pageMode = PageMode.featuredActivities; - Room? get room => Matrix.of(context).client.getRoomById(widget.roomID); + Room? get room => widget.roomID != null + ? Matrix.of(context).client.getRoomById(widget.roomID!) + : null; void _setPageMode(PageMode? mode) { if (mode == null) return; diff --git a/lib/pangea/activity_planner/activity_planner_page_appbar.dart b/lib/pangea/activity_planner/activity_planner_page_appbar.dart index 9c86b34b9..36dae1223 100644 --- a/lib/pangea/activity_planner/activity_planner_page_appbar.dart +++ b/lib/pangea/activity_planner/activity_planner_page_appbar.dart @@ -11,11 +11,11 @@ import 'package:fluffychat/pangea/common/widgets/customized_svg.dart'; class ActivityPlannerPageAppBar extends StatelessWidget implements PreferredSizeWidget { final PageMode pageMode; - final String roomID; + final String? roomID; const ActivityPlannerPageAppBar({ required this.pageMode, - required this.roomID, + this.roomID, super.key, }); @@ -68,7 +68,9 @@ class ActivityPlannerPageAppBar extends StatelessWidget alignment: Alignment.center, child: InkWell( customBorder: const CircleBorder(), - onTap: () => context.go('/rooms/$roomID/planner/generator'), + onTap: () => roomID != null + ? context.go('/rooms/$roomID/planner/generator') + : context.go("/rooms/homepage/planner/generator"), child: Container( decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHighest, diff --git a/lib/pangea/activity_planner/bookmarked_activity_list.dart b/lib/pangea/activity_planner/bookmarked_activity_list.dart index a94d5f279..168f5d149 100644 --- a/lib/pangea/activity_planner/bookmarked_activity_list.dart +++ b/lib/pangea/activity_planner/bookmarked_activity_list.dart @@ -7,6 +7,7 @@ import 'package:matrix/matrix.dart'; import 'package:fluffychat/config/themes.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/activity_planner_page.dart'; import 'package:fluffychat/pangea/activity_planner/bookmarked_activities_repo.dart'; import 'package:fluffychat/pangea/activity_suggestions/activity_suggestion_card.dart'; @@ -97,11 +98,16 @@ class BookmarkedActivitiesListState extends State { showDialog( context: context, builder: (context) { - return ActivitySuggestionDialog( + return ActivityPlannerBuilder( initialActivity: activity, - buttonText: L10n.of(context).inviteAndLaunch, - room: widget.room, onEdit: _onEdit, + room: widget.room, + builder: (controller) { + return ActivitySuggestionDialog( + controller: controller, + buttonText: l10n.launch, + ); + }, ); }, ); diff --git a/lib/pangea/activity_suggestions/activity_room_selection.dart b/lib/pangea/activity_suggestions/activity_room_selection.dart new file mode 100644 index 000000000..a2523b7d1 --- /dev/null +++ b/lib/pangea/activity_suggestions/activity_room_selection.dart @@ -0,0 +1,627 @@ +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:flutter_gen/gen_l10n/l10n.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/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()); + }); + + _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, + avatarURL: widget.controller.imageURL, + ); + _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, + StateEvent( + type: EventTypes.RoomPowerLevels, + stateKey: '', + content: 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, + avatarURL: widget.controller.imageURL, + ); + } + _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_carousel.dart b/lib/pangea/activity_suggestions/activity_suggestion_carousel.dart index 3a31c89ac..d6b4d11f1 100644 --- a/lib/pangea/activity_suggestions/activity_suggestion_carousel.dart +++ b/lib/pangea/activity_suggestions/activity_suggestion_carousel.dart @@ -11,6 +11,7 @@ import 'package:shimmer/shimmer.dart'; import 'package:fluffychat/config/themes.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'; @@ -44,7 +45,6 @@ class ActivitySuggestionCarousel extends StatefulWidget { class ActivitySuggestionCarouselState extends State { - bool _isOpen = true; bool _loading = true; String? _error; @@ -138,7 +138,6 @@ class ActivitySuggestionCarouselState void _close() { widget.onActivitySelected(null, null, null); - setState(() => _isOpen = false); } void _onClickCard() { @@ -150,13 +149,23 @@ class ActivitySuggestionCarouselState ); return; } + showDialog( context: context, builder: (context) { - return ActivitySuggestionDialog( + return ActivityPlannerBuilder( initialActivity: _currentActivity!, - buttonText: L10n.of(context).selectActivity, - onLaunch: widget.onActivitySelected, + builder: (controller) { + return ActivitySuggestionDialog( + controller: controller, + buttonText: L10n.of(context).selectActivity, + onLaunch: () => widget.onActivitySelected( + controller.updatedActivity, + controller.avatar, + controller.filename, + ), + ); + }, ); }, ); @@ -167,164 +176,156 @@ class ActivitySuggestionCarouselState final theme = Theme.of(context); return AnimatedSize( duration: FluffyThemes.animationDuration, - child: !_isOpen - ? const SizedBox.shrink() - : AnimatedOpacity( - duration: FluffyThemes.animationDuration, - opacity: widget.enabled ? 1.0 : 0.5, - child: 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, + child: AnimatedOpacity( + duration: FluffyThemes.animationDuration, + opacity: widget.enabled ? 1.0 : 0.5, + child: 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: [ - 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, - ), - ], - ), + Text( + L10n.of(context).newChatActivityTitle, + style: theme.textTheme.titleLarge, ), - 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(), + 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 effc1dfb7..3a1282b3f 100644 --- a/lib/pangea/activity_suggestions/activity_suggestion_dialog.dart +++ b/lib/pangea/activity_suggestions/activity_suggestion_dialog.dart @@ -1,54 +1,34 @@ -import 'dart:ui'; - -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:collection/collection.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:go_router/go_router.dart'; -import 'package:http/http.dart' as http; -import 'package:http/http.dart'; import 'package:material_symbols_icons/symbols.dart'; -import 'package:matrix/matrix.dart' as sdk; -import 'package:matrix/matrix.dart'; import 'package:fluffychat/config/themes.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/constants/default_power_level.dart'; -import 'package:fluffychat/pangea/common/utils/error_handler.dart'; -import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; -import 'package:fluffychat/utils/client_download_content_extension.dart'; -import 'package:fluffychat/utils/file_selector.dart'; +import 'package:fluffychat/pangea/common/widgets/full_width_dialog.dart'; import 'package:fluffychat/widgets/future_loading_dialog.dart'; -import 'package:fluffychat/widgets/matrix.dart'; import 'package:fluffychat/widgets/mxc_image.dart'; +enum _PageMode { + activity, + roomSelection, +} + class ActivitySuggestionDialog extends StatefulWidget { - final ActivityPlanModel initialActivity; + final ActivityPlannerBuilderState controller; final String buttonText; - final Room? room; - final Function( - ActivityPlanModel, - Uint8List?, - String?, - )? onLaunch; - - final Future Function( - String, - ActivityPlanModel, - Uint8List?, - String?, - )? onEdit; + final VoidCallback? onLaunch; const ActivitySuggestionDialog({ - required this.initialActivity, + required this.controller, required this.buttonText, this.onLaunch, - this.onEdit, - this.room, super.key, }); @@ -58,218 +38,35 @@ class ActivitySuggestionDialog extends StatefulWidget { } class ActivitySuggestionDialogState extends State { - bool _isEditing = false; - Uint8List? _avatar; - String? _imageURL; - String? _filename; + _PageMode _pageMode = _PageMode.activity; - final TextEditingController _titleController = TextEditingController(); - final TextEditingController _instructionsController = TextEditingController(); - final TextEditingController _vocabController = TextEditingController(); - final TextEditingController _participantsController = TextEditingController(); - final TextEditingController _learningObjectivesController = - TextEditingController(); - - // storing this separately so that we can dismiss edits, - // rather than directly modifying the activity with each change - final List _vocab = []; - - final GlobalKey _formKey = GlobalKey(); - - @override - void initState() { - super.initState(); - _titleController.text = widget.initialActivity.title; - _learningObjectivesController.text = - widget.initialActivity.learningObjective; - _instructionsController.text = widget.initialActivity.instructions; - _participantsController.text = - widget.initialActivity.req.numberOfParticipants.toString(); - _vocab.addAll(widget.initialActivity.vocab); - _imageURL = widget.initialActivity.imageURL; - _setAvatarByURL(); - } - - @override - void dispose() { - _titleController.dispose(); - _learningObjectivesController.dispose(); - _instructionsController.dispose(); - _vocabController.dispose(); - _participantsController.dispose(); - super.dispose(); - } - - void _setEditing(bool editting) { - _isEditing = editting; - if (mounted) setState(() {}); - } - - void _setAvatar() async { - final photo = await selectFiles( - context, - type: FileSelectorType.images, - allowMultiple: false, - ); - final bytes = await photo.singleOrNull?.readAsBytes(); - if (mounted) { - setState(() { - _avatar = bytes; - _filename = photo.singleOrNull?.name; - }); - } - } - - Future _setAvatarByURL() async { - if (widget.initialActivity.imageURL == null) return; - try { - if (_avatar == null) { - if (widget.initialActivity.imageURL!.startsWith("mxc")) { - final client = Matrix.of(context).client; - final mxcUri = Uri.parse(widget.initialActivity.imageURL!); - final data = await client.downloadMxcCached(mxcUri); - _avatar = data; - _filename = Uri.encodeComponent( - mxcUri.pathSegments.last, - ); - } else { - final Response response = - await http.get(Uri.parse(widget.initialActivity.imageURL!)); - _avatar = response.bodyBytes; - _filename = Uri.encodeComponent( - Uri.parse(widget.initialActivity.imageURL!).pathSegments.last, - ); - } - } - } catch (err, s) { - ErrorHandler.logError( - e: err, - s: s, - data: { - "imageURL": widget.initialActivity.imageURL, - }, - ); - } - } - - void _clearEdits() { - _avatar = null; - _filename = null; - _setAvatarByURL(); - _vocab.clear(); - _vocab.addAll(widget.initialActivity.vocab); - if (mounted) setState(() {}); - } - - ActivityPlanModel get _updatedActivity => ActivityPlanModel( - req: widget.initialActivity.req, - title: _titleController.text, - learningObjective: _learningObjectivesController.text, - instructions: _instructionsController.text, - vocab: _vocab, - imageURL: _imageURL, - ); - - Future _updateImageURL() async { - if (_avatar == null) return; - final url = await Matrix.of(context).client.uploadContent( - _avatar!, - filename: _filename, - ); - if (!mounted) return; - setState(() { - _imageURL = url.toString(); - }); - } - - void _addVocab() { - _vocab.insert( - 0, - Vocab( - lemma: _vocabController.text.trim(), - pos: "", - ), - ); - _vocabController.clear(); - if (mounted) setState(() {}); - } - - void _removeVocab(int index) { - _vocab.removeAt(index); - if (mounted) setState(() {}); - } + double get _width => FluffyThemes.isColumnMode(context) + ? 400.0 + : MediaQuery.of(context).size.width; Future _launchActivity() async { - await _updateImageURL(); - - if (widget.room != null) { - await widget.room!.sendActivityPlan( - _updatedActivity, - avatar: _avatar, - filename: _filename, - ); - context.go("/rooms/${widget.room!.id}/invite"); - return; - } - - final client = Matrix.of(context).client; - final roomId = await client.createGroupChat( - preset: CreateRoomPreset.publicChat, - visibility: sdk.Visibility.private, - groupName: _updatedActivity.title, - initialState: [ - if (_updatedActivity.imageURL != null) - StateEvent( - type: EventTypes.RoomAvatar, - stateKey: '', - content: { - "url": _updatedActivity.imageURL, - }, - ), - StateEvent( - type: EventTypes.RoomPowerLevels, - stateKey: '', - content: defaultPowerLevels(client.userID!), - ), - ], - enableEncryption: false, - ); - - Room? room = Matrix.of(context).client.getRoomById(roomId); - if (room == null) { - await client.waitForRoomInSync(roomId); - room = Matrix.of(context).client.getRoomById(roomId); - if (room == null) return; - } - - await room.sendActivityPlan( - _updatedActivity, - avatar: _avatar, - filename: _filename, - ); - - context.go("/rooms/$roomId/invite?filter=groups"); - } - - Future _saveEdits() async { - if (!_formKey.currentState!.validate()) return; - await _updateImageURL(); - _setEditing(false); - if (widget.onEdit != null) { - await widget.onEdit!( - widget.initialActivity.bookmarkId, - _updatedActivity, - _avatar, - _filename, + if (widget.onLaunch != null) { + await widget.controller.updateImageURL(); + widget.onLaunch!.call(); + Navigator.of(context).pop(); + } else if (widget.controller.room != null) { + 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); } } - double get width { - if (FluffyThemes.isColumnMode(context)) { - return 400.0; - } - return MediaQuery.of(context).size.width; + void _setPageMode(_PageMode mode) { + setState(() { + _pageMode = mode; + }); } @override @@ -278,421 +75,479 @@ class ActivitySuggestionDialogState extends State { final body = Stack( alignment: Alignment.topCenter, children: [ - Form( - key: _formKey, - child: Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Stack( - alignment: Alignment.center, - children: [ - Container( - constraints: const BoxConstraints( - maxHeight: 400.0, - ), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(24.0), - ), - width: width, - child: ClipRRect( - borderRadius: BorderRadius.circular(24.0), - child: _avatar != null - ? Image.memory(_avatar!, fit: BoxFit.cover) - : _updatedActivity.imageURL != null - ? _updatedActivity.imageURL!.startsWith("mxc") - ? MxcImage( - uri: Uri.parse( - _updatedActivity.imageURL!, + DecoratedBox( + decoration: BoxDecoration( + color: theme.colorScheme.surface, + ), + child: _pageMode == _PageMode.activity + ? 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.center, + children: [ + SizedBox( + width: _width, + 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, + 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) + Positioned( + bottom: 8.0, + child: InkWell( + borderRadius: BorderRadius.circular(90), + onTap: widget.controller.selectAvatar, + child: const CircleAvatar( + radius: 24.0, + child: Icon( + Icons.add_a_photo_outlined, + size: 24.0, + ), + ), ), - width: width, - height: 200, - cacheKey: _updatedActivity.bookmarkId, - fit: BoxFit.cover, - ) - : CachedNetworkImage( - imageUrl: _updatedActivity.imageURL!, - fit: BoxFit.cover, - placeholder: (context, url) => - const Center( - child: CircularProgressIndicator(), + ), + ], + ), + 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, + ), + maxLines: 6, + overflow: TextOverflow.ellipsis, + ), ), - errorWidget: (context, url, error) => - const SizedBox(), - ) - : null, - ), - ), - if (_isEditing) - Positioned( - bottom: 8.0, - child: InkWell( - borderRadius: BorderRadius.circular(90), - onTap: _setAvatar, - child: const CircleAvatar( - radius: 24.0, - child: Icon( - Icons.add_a_photo_outlined, - size: 24.0, - ), - ), - ), - ), - ], - ), - Flexible( - child: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - spacing: 8.0, - mainAxisSize: MainAxisSize.min, - children: [ - if (_isEditing) - ActivitySuggestionCardRow( - icon: Icons.event_note_outlined, - child: TextFormField( - controller: _titleController, - decoration: InputDecoration( - labelText: L10n.of(context).activityTitle, - ), - maxLines: 2, - minLines: 1, - ), - ) - else - ActivitySuggestionCardRow( - icon: Icons.event_note_outlined, - child: Text( - _updatedActivity.title, - style: theme.textTheme.titleLarge - ?.copyWith(fontWeight: FontWeight.bold), - maxLines: 6, - overflow: TextOverflow.ellipsis, - ), - ), - if (_isEditing) - ActivitySuggestionCardRow( - icon: Symbols.target, - child: TextFormField( - controller: _learningObjectivesController, - decoration: InputDecoration( - labelText: - L10n.of(context).learningObjectiveLabel, - ), - maxLines: 4, - minLines: 1, - ), - ) - else - ActivitySuggestionCardRow( - icon: Symbols.target, - child: Text( - _updatedActivity.learningObjective, - maxLines: 6, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodyLarge, - ), - ), - if (_isEditing) - ActivitySuggestionCardRow( - icon: Symbols.steps, - child: TextFormField( - controller: _instructionsController, - decoration: InputDecoration( - labelText: L10n.of(context).instructions, - ), - maxLines: 8, - minLines: 1, - ), - ) - else - ActivitySuggestionCardRow( - icon: Symbols.steps, - child: Text( - _updatedActivity.instructions, - maxLines: 8, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodyLarge, - ), - ), - if (_isEditing) - ActivitySuggestionCardRow( - icon: Icons.group_outlined, - child: TextFormField( - controller: _participantsController, - decoration: InputDecoration( - labelText: L10n.of(context).classRoster, - ), - maxLines: 1, - keyboardType: TextInputType.number, - validator: (value) { - if (value == null || value.isEmpty) { - return null; - } + 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, + maxLines: 6, + overflow: TextOverflow.ellipsis, + 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, + maxLines: 8, + overflow: TextOverflow.ellipsis, + 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; - } - } catch (e) { - return L10n.of(context).pleaseEnterANumber; - } - return null; - }, - ), - ) - else - ActivitySuggestionCardRow( - icon: Icons.group_outlined, - child: Text( - L10n.of(context).countParticipants( - _updatedActivity.req.numberOfParticipants, - ), - style: theme.textTheme.bodyLarge, - ), - ), - if (_isEditing) - ActivitySuggestionCardRow( - icon: Symbols.dictionary, - child: ConstrainedBox( - constraints: - const BoxConstraints(maxHeight: 60.0), - child: SingleChildScrollView( - child: Wrap( - spacing: 4.0, - runSpacing: 4.0, - children: _vocab - .mapIndexed( - (i, vocab) => Container( - padding: const EdgeInsets.symmetric( - vertical: 4.0, - horizontal: 8.0, + try { + final val = int.parse(value); + if (val <= 0) { + return L10n.of(context) + .pleaseEnterInt; + } + } 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, ), - decoration: BoxDecoration( - color: theme.colorScheme.primary - .withAlpha(20), - borderRadius: - BorderRadius.circular(24.0), + style: theme.textTheme.bodyLarge, + ), + ), + if (widget.controller.isEditing) + ActivitySuggestionCardRow( + icon: Symbols.dictionary, + child: ConstrainedBox( + constraints: const BoxConstraints( + maxHeight: 60.0, ), - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: GestureDetector( - onTap: () => _removeVocab(i), - child: Row( - spacing: 4.0, - mainAxisSize: MainAxisSize.min, - children: [ - Text(vocab.lemma), - const Icon( - Icons.close, - size: 12.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(), ), ), ), ) - .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: _vocab - .map( - (vocab) => Container( - padding: const EdgeInsets.symmetric( - vertical: 4.0, - horizontal: 8.0, + else + ActivitySuggestionCardRow( + icon: Symbols.dictionary, + child: ConstrainedBox( + constraints: const BoxConstraints( + maxHeight: 60.0, ), - decoration: BoxDecoration( - color: theme.colorScheme.primary - .withAlpha(20), - borderRadius: - BorderRadius.circular(24.0), - ), - child: Text( - vocab.lemma, - style: theme.textTheme.bodyMedium, + 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(), + ), ), ), - ) - .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, + ), + ], + ), + ), + ], ), ), - ), + ], ), - if (_isEditing) - Padding( - padding: const EdgeInsets.symmetric(vertical: 4.0), - child: Row( - spacing: 4.0, - children: [ - Expanded( - child: TextFormField( - controller: _vocabController, - decoration: InputDecoration( - hintText: L10n.of(context).addVocabulary, + ), + ), + Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + spacing: 6.0, + children: [ + if (widget.controller.isEditing) + Expanded( + child: ElevatedButton( + onPressed: widget.controller.saveEdits, + 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, + ), + child: Text( + L10n.of(context).save, + style: theme.textTheme.bodyLarge?.copyWith( + color: theme.colorScheme.onPrimary, ), - maxLines: 1, - onFieldSubmitted: (_) => _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: _addVocab, + ) + else + Expanded( + child: ElevatedButton( + onPressed: () async { + if (!widget.controller.formKey.currentState! + .validate()) { + return; + } + _launchActivity(); + }, + 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, + ), + child: Text( + widget.buttonText, + style: theme.textTheme.bodyLarge?.copyWith( + color: theme.colorScheme.onPrimary, + ), + ), ), - ], - ), - ), - ], + ), + if (widget.controller.isEditing) + IconButton.filled( + style: IconButton.styleFrom( + backgroundColor: theme.colorScheme.primary, + ), + padding: const EdgeInsets.all(6.0), + constraints: + const BoxConstraints(), // override default min size of 48px + iconSize: 24.0, + icon: const Icon(Icons.close_outlined), + onPressed: () async { + await widget.controller.clearEdits(); + widget.controller.setEditing(false); + }, + ) + else + IconButton.filled( + style: IconButton.styleFrom( + backgroundColor: theme.colorScheme.primary, + ), + padding: const EdgeInsets.all(6.0), + constraints: + const BoxConstraints(), // override default min size of 48px + iconSize: 24.0, + icon: const Icon(Icons.edit_outlined), + onPressed: () => + widget.controller.setEditing(true), + ), + ], + ), + ), + ], + ), + ) + : ActivityRoomSelection( + controller: widget.controller, + backButton: BackButton( + onPressed: () => _setPageMode( + _PageMode.activity, ), ), ), - ), - Padding( - padding: const EdgeInsets.all(16.0), - child: Row( - spacing: 6.0, - children: [ - if (_isEditing && widget.onEdit != null) - Expanded( - child: ElevatedButton( - onPressed: _saveEdits, - 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, - ), - child: Text( - L10n.of(context).save, - style: theme.textTheme.bodyLarge - ?.copyWith(color: theme.colorScheme.onPrimary), - ), - ), - ) - else - Expanded( - child: ElevatedButton( - onPressed: () async { - if (!_formKey.currentState!.validate()) { - return; - } - final resp = await showFutureLoadingDialog( - context: context, - future: () async { - if (widget.onLaunch != null) { - await _updateImageURL(); - - widget.onLaunch!.call( - _updatedActivity, - _avatar, - _filename, - ); - } else { - await _launchActivity(); - } - }, - ); - - if (resp.isError) return; - Navigator.of(context).pop(); - }, - 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, - ), - child: Text( - widget.buttonText, - style: theme.textTheme.bodyLarge - ?.copyWith(color: theme.colorScheme.onPrimary), - ), - ), - ), - if (_isEditing) - GestureDetector( - child: const Icon(Icons.close_outlined, size: 16.0), - onTap: () { - _clearEdits(); - _setEditing(false); - }, - ) - else - IconButton.filled( - style: IconButton.styleFrom( - backgroundColor: theme.colorScheme.primary, - ), - padding: const EdgeInsets.all(6.0), - constraints: - const BoxConstraints(), // override default min size of 48px - iconSize: 24.0, - icon: const Icon(Icons.edit_outlined), - onPressed: () => _setEditing(true), - ), - ], - ), - ), - ], - ), ), - Positioned( - top: 4.0, - left: 4.0, - child: IconButton( - icon: const Icon(Icons.close_outlined), - onPressed: Navigator.of(context).pop, - tooltip: L10n.of(context).close, + if (_pageMode == _PageMode.activity) + Positioned( + top: 4.0, + left: 4.0, + child: IconButton( + icon: const Icon(Icons.close_outlined), + onPressed: Navigator.of(context).pop, + tooltip: L10n.of(context).close, + ), ), - ), ], ); - final content = AnimatedSize( - duration: FluffyThemes.animationDuration, - child: ConstrainedBox( - constraints: FluffyThemes.isColumnMode(context) - ? BoxConstraints(maxWidth: width) - : BoxConstraints( - maxWidth: width, - maxHeight: MediaQuery.of(context).size.height, - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(20.0), - child: body, - ), - ), - ); - - return BackdropFilter( - filter: ImageFilter.blur(sigmaX: 2.5, sigmaY: 2.5), - child: FluffyThemes.isColumnMode(context) - ? Dialog(child: content) - : Dialog.fullscreen(child: content), + return FullWidthDialog( + dialogContent: body, + maxWidth: _width, + maxHeight: 650.0, ); } } diff --git a/lib/pangea/activity_suggestions/activity_suggestions_area.dart b/lib/pangea/activity_suggestions/activity_suggestions_area.dart index 3e8e54799..e7c3335d3 100644 --- a/lib/pangea/activity_suggestions/activity_suggestions_area.dart +++ b/lib/pangea/activity_suggestions/activity_suggestions_area.dart @@ -10,16 +10,14 @@ import 'package:go_router/go_router.dart'; import 'package:matrix/matrix.dart'; import 'package:shimmer/shimmer.dart'; -import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/config/themes.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/activity_suggestions/activity_suggestions_constants.dart'; -import 'package:fluffychat/pangea/common/widgets/customized_svg.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'; @@ -135,10 +133,15 @@ class ActivitySuggestionsAreaState extends State { showDialog( context: context, builder: (context) { - return ActivitySuggestionDialog( + return ActivityPlannerBuilder( initialActivity: activity, - buttonText: L10n.of(context).inviteAndLaunch, room: widget.room, + builder: (controller) { + return ActivitySuggestionDialog( + controller: controller, + buttonText: L10n.of(context).launch, + ); + }, ); }, ); @@ -165,7 +168,7 @@ class ActivitySuggestionsAreaState extends State { children: [ Flexible( child: Text( - L10n.of(context).startChat, + L10n.of(context).chatWithActivities, style: isColumnMode ? theme.textTheme.titleLarge ?.copyWith(fontWeight: FontWeight.bold) @@ -175,91 +178,10 @@ class ActivitySuggestionsAreaState extends State { overflow: TextOverflow.ellipsis, ), ), - Material( - type: MaterialType.transparency, - child: Row( - spacing: 8.0, - children: [ - InkWell( - customBorder: const CircleBorder(), - onTap: () => context.go('/homepage/newgroup'), - child: Container( - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(36.0), - ), - padding: const EdgeInsets.symmetric( - vertical: 6.0, - horizontal: 10.0, - ), - child: Row( - spacing: 8.0, - mainAxisSize: MainAxisSize.min, - children: [ - CustomizedSvg( - svgUrl: - "${AppConfig.assetsBaseURL}/${ActivitySuggestionsConstants.plusIconPath}", - colorReplacements: { - "#CDBEF9": colorToHex( - Theme.of(context).colorScheme.secondary, - ), - }, - height: 16.0, - width: 16.0, - ), - Text( - isColumnMode - ? L10n.of(context).createOwnChat - : L10n.of(context).chat, - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ), - ), - InkWell( - customBorder: const CircleBorder(), - onTap: () => context.go('/homepage/planner'), - child: Container( - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(36.0), - ), - padding: const EdgeInsets.symmetric( - vertical: 6.0, - horizontal: 10.0, - ), - child: Row( - spacing: 8.0, - mainAxisSize: MainAxisSize.min, - children: [ - CustomizedSvg( - svgUrl: - "${AppConfig.assetsBaseURL}/${ActivitySuggestionsConstants.crayonIconPath}", - colorReplacements: { - "#CDBEF9": colorToHex( - Theme.of(context).colorScheme.secondary, - ), - }, - height: 16.0, - width: 16.0, - ), - Text( - isColumnMode - ? L10n.of(context).makeYourOwnActivity - : L10n.of(context).createActivity, - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ), - ), - ], - ), + IconButton( + icon: const Icon(Icons.menu_outlined), + onPressed: () => context.go('/rooms/homepage/planner'), + tooltip: L10n.of(context).activityPlannerTitle, ), ], ), diff --git a/lib/pangea/activity_suggestions/suggestions_page.dart b/lib/pangea/activity_suggestions/suggestions_page.dart index 73115cade..e46be8e91 100644 --- a/lib/pangea/activity_suggestions/suggestions_page.dart +++ b/lib/pangea/activity_suggestions/suggestions_page.dart @@ -1,9 +1,13 @@ import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/pangea/activity_suggestions/activity_suggestions_area.dart'; import 'package:fluffychat/pangea/analytics_summary/learning_progress_indicators.dart'; import 'package:fluffychat/pangea/public_spaces/public_spaces_area.dart'; +import 'package:fluffychat/widgets/navigation_rail.dart'; class SuggestionsPage extends StatelessWidget { const SuggestionsPage({super.key}); @@ -11,25 +15,45 @@ class SuggestionsPage extends StatelessWidget { @override Widget build(BuildContext context) { final isColumnMode = FluffyThemes.isColumnMode(context); - return SafeArea( - child: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 24.0, - vertical: 16.0, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - spacing: 24.0, - children: [ - if (!isColumnMode) const LearningProgressIndicators(), - const ActivitySuggestionsArea( - showTitle: true, - scrollDirection: Axis.horizontal, + return Material( + child: SafeArea( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!isColumnMode && AppConfig.displayNavigationRail) ...[ + SpacesNavigationRail( + activeSpaceId: null, + onGoToChats: () => context.go('/rooms'), + onGoToSpaceId: (spaceId) => + context.go('/rooms?spaceId=$spaceId'), + ), + Container( + color: Theme.of(context).dividerColor, + width: 1, ), - const PublicSpacesArea(), ], - ), + Expanded( + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 24.0, + vertical: 16.0, + ), + child: Column( + spacing: 24.0, + children: [ + if (!isColumnMode) const LearningProgressIndicators(), + const ActivitySuggestionsArea( + showTitle: true, + scrollDirection: Axis.horizontal, + ), + const PublicSpacesArea(), + ], + ), + ), + ), + ), + ], ), ), ); diff --git a/lib/pangea/analytics_details_popup/vocab_analytics_details_view.dart b/lib/pangea/analytics_details_popup/vocab_analytics_details_view.dart index 183ef1ae4..18a4d1d77 100644 --- a/lib/pangea/analytics_details_popup/vocab_analytics_details_view.dart +++ b/lib/pangea/analytics_details_popup/vocab_analytics_details_view.dart @@ -56,6 +56,7 @@ class VocabDetailsView extends StatelessWidget { ), iconSize: _iconSize, uniqueID: "${_construct.lemma}-${_construct.category}", + langCode: _userL2!, ), subtitle: Column( children: [ @@ -140,8 +141,12 @@ class VocabDetailsView extends StatelessWidget { children: [ WordTextWithAudioButton( text: form, - style: Theme.of(context).textTheme.bodyLarge, + style: + Theme.of(context).textTheme.bodyLarge?.copyWith( + color: textColor, + ), uniqueID: "$form-${_construct.lemma}-$i", + langCode: _userL2!, ), if (i != forms.length - 1) const Text(", "), ], diff --git a/lib/pangea/analytics_summary/learning_progress_indicators.dart b/lib/pangea/analytics_summary/learning_progress_indicators.dart index ac28bc546..76d07588a 100644 --- a/lib/pangea/analytics_summary/learning_progress_indicators.dart +++ b/lib/pangea/analytics_summary/learning_progress_indicators.dart @@ -105,147 +105,156 @@ class LearningProgressIndicatorsState final mxid = client.userID ?? L10n.of(context).user; final displayname = _profile?.displayName ?? mxid.localpart ?? mxid; - return Row( - children: [ - Tooltip( - message: L10n.of(context).settings, - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: GestureDetector( - onTap: () => context.go("/rooms/settings"), - child: Padding( - padding: const EdgeInsets.only( - bottom: 8.0, - right: 8.0, - ), - child: Stack( - clipBehavior: Clip.none, // Allow overflow - children: [ - FutureBuilder( - future: client.fetchOwnProfile(), - builder: (context, snapshot) => Stack( - alignment: Alignment.center, - children: [ - Material( - color: Colors.transparent, - borderRadius: BorderRadius.circular(99), - child: Avatar( - mxContent: snapshot.data?.avatarUrl, - name: snapshot.data?.displayName ?? - client.userID!.localpart, - size: 60, - ), - ), - ], - ), - ), - Positioned( - bottom: -3, - right: -3, - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(15), - color: Theme.of(context).colorScheme.surfaceBright, - ), - padding: const EdgeInsets.all(4.0), - child: Icon( - size: 14, - Icons.settings_outlined, - color: Theme.of(context).colorScheme.primary, - weight: 1000, - ), - ), - ), - ], - ), - ), - ), - ), - ), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - spacing: 6.0, - children: [ - Text( - displayname, - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.primary, - ), - ), - LearningSettingsButton( - onTap: () => showDialog( - context: context, - builder: (c) => const SettingsLearning(), - barrierDismissible: false, - ), - l2: userL2?.langCode.toUpperCase(), - ), - ], - ), - const SizedBox(height: 6), - Row( - spacing: 6.0, - children: ConstructTypeEnum.values - .map( - (c) => ProgressIndicatorBadge( - points: uniqueLemmas(c.indicator), - loading: _loading, - onTap: () { - showDialog( - context: context, - builder: (context) => AnalyticsPopupWrapper( - view: c, - ), - ); - }, - indicator: c.indicator, - ), - ) - .toList(), - ), - const SizedBox(height: 6), - MouseRegion( + return LayoutBuilder( + builder: (context, constraints) { + return Row( + children: [ + Tooltip( + message: L10n.of(context).settings, + child: MouseRegion( cursor: SystemMouseCursors.click, child: GestureDetector( - onTap: () { - showDialog( - context: context, - builder: (c) => const LevelBarPopup(), - ); - }, - child: SizedBox( - height: 26, + onTap: () => context.go("/rooms/settings"), + child: Padding( + padding: const EdgeInsets.only( + bottom: 8.0, + right: 8.0, + ), child: Stack( - alignment: Alignment.center, + clipBehavior: Clip.none, // Allow overflow children: [ - Positioned( - left: 16, - right: 0, - child: LearningProgressBar( - level: _constructsModel.level, - totalXP: _constructsModel.totalXP, + FutureBuilder( + future: client.fetchOwnProfile(), + builder: (context, snapshot) => Stack( + alignment: Alignment.center, + children: [ + Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(99), + child: Avatar( + mxContent: snapshot.data?.avatarUrl, + name: snapshot.data?.displayName ?? + client.userID!.localpart, + size: 60, + ), + ), + ], ), ), Positioned( - left: 0, - child: LevelBadge(level: _constructsModel.level), + bottom: -3, + right: -3, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(15), + color: + Theme.of(context).colorScheme.surfaceBright, + ), + padding: const EdgeInsets.all(4.0), + child: Icon( + size: 14, + Icons.settings_outlined, + color: Theme.of(context).colorScheme.primary, + weight: 1000, + ), + ), ), ], ), ), ), ), - ], - ), - ), - ], + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + spacing: 6.0, + children: [ + Text( + displayname, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.primary, + ), + ), + LearningSettingsButton( + onTap: () => showDialog( + context: context, + builder: (c) => const SettingsLearning(), + barrierDismissible: false, + ), + l2: userL2?.langCode.toUpperCase(), + ), + ], + ), + const SizedBox(height: 6), + Row( + spacing: 6.0, + children: ConstructTypeEnum.values + .map( + (c) => ProgressIndicatorBadge( + points: uniqueLemmas(c.indicator), + loading: _loading, + onTap: () { + showDialog( + context: context, + builder: (context) => AnalyticsPopupWrapper( + view: c, + ), + ); + }, + indicator: c.indicator, + mini: constraints.maxWidth < 300, + ), + ) + .toList(), + ), + const SizedBox(height: 6), + MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () { + showDialog( + context: context, + builder: (c) => const LevelBarPopup(), + ); + }, + child: SizedBox( + height: 26, + child: Stack( + alignment: Alignment.center, + children: [ + Positioned( + left: 16, + right: 0, + child: LearningProgressBar( + level: _constructsModel.level, + totalXP: _constructsModel.totalXP, + ), + ), + Positioned( + left: 0, + child: LevelBadge( + level: _constructsModel.level, + mini: constraints.maxWidth < 300, + ), + ), + ], + ), + ), + ), + ), + ], + ), + ), + ], + ); + }, ); } } diff --git a/lib/pangea/analytics_summary/level_badge.dart b/lib/pangea/analytics_summary/level_badge.dart index 1282c1c41..f1bd6da1b 100644 --- a/lib/pangea/analytics_summary/level_badge.dart +++ b/lib/pangea/analytics_summary/level_badge.dart @@ -2,14 +2,16 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; -import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/pangea/analytics_summary/level_bar_popup.dart'; import 'package:fluffychat/pangea/common/widgets/pressable_button.dart'; class LevelBadge extends StatelessWidget { final int level; + final bool mini; + const LevelBadge({ required this.level, + this.mini = false, super.key, }); @@ -31,29 +33,13 @@ class LevelBadge extends StatelessWidget { color: Theme.of(context).colorScheme.surfaceBright, ), padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - CircleAvatar( - backgroundColor: AppConfig.gold, - radius: 8, - child: Icon( - size: 12, - Icons.star, - color: Theme.of(context).colorScheme.surfaceBright, - weight: 1000, - ), - ), - const SizedBox(width: 4), - Text( - L10n.of(context).levelShort(level), - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.primary, - ), - ), - ], + child: Text( + "⭐ ${mini ? "$level" : L10n.of(context).levelShort(level)}", + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.primary, + ), ), ), ); diff --git a/lib/pangea/analytics_summary/level_bar_popup.dart b/lib/pangea/analytics_summary/level_bar_popup.dart index 700b9fee8..8d0ec2e30 100644 --- a/lib/pangea/analytics_summary/level_bar_popup.dart +++ b/lib/pangea/analytics_summary/level_bar_popup.dart @@ -43,27 +43,13 @@ class LevelBarPopup extends StatelessWidget { child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Row( - children: [ - const CircleAvatar( - radius: 20, - backgroundColor: AppConfig.gold, - child: Icon( - size: 30, - Icons.star, - color: Colors.white, - ), - ), - const SizedBox(width: 10), - Text( - L10n.of(context).levelShort(level), - style: const TextStyle( - fontSize: 24, - fontWeight: FontWeight.w900, - color: AppConfig.gold, - ), - ), - ], + Text( + "⭐ ${L10n.of(context).levelShort(level)}", + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.w900, + color: AppConfig.gold, + ), ), Opacity( opacity: 0.25, diff --git a/lib/pangea/analytics_summary/progress_bar/animated_level_dart.dart b/lib/pangea/analytics_summary/progress_bar/animated_level_dart.dart index 72144756d..7257a4f4b 100644 --- a/lib/pangea/analytics_summary/progress_bar/animated_level_dart.dart +++ b/lib/pangea/analytics_summary/progress_bar/animated_level_dart.dart @@ -95,14 +95,6 @@ class AnimatedLevelBarState extends State borderRadius: const BorderRadius.all( Radius.circular(AppConfig.borderRadius), ), - boxShadow: [ - BoxShadow( - color: Colors.black.withAlpha(50), - spreadRadius: 0, - blurRadius: 5, - offset: const Offset(5, 0), - ), - ], ), ), Positioned( diff --git a/lib/pangea/analytics_summary/progress_indicator.dart b/lib/pangea/analytics_summary/progress_indicator.dart index faaaa46b6..aa99cf40d 100644 --- a/lib/pangea/analytics_summary/progress_indicator.dart +++ b/lib/pangea/analytics_summary/progress_indicator.dart @@ -9,6 +9,7 @@ class ProgressIndicatorBadge extends StatelessWidget { final int points; final VoidCallback onTap; final ProgressIndicatorEnum indicator; + final bool mini; const ProgressIndicatorBadge({ super.key, @@ -16,6 +17,7 @@ class ProgressIndicatorBadge extends StatelessWidget { required this.indicator, required this.loading, required this.points, + this.mini = false, }); @override @@ -42,15 +44,17 @@ class ProgressIndicatorBadge extends StatelessWidget { color: indicator.color(context), weight: 1000, ), - const SizedBox(width: 4.0), - Text( - indicator.tooltip(context), - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - color: indicator.color(context), + if (!mini) ...[ + const SizedBox(width: 4.0), + Text( + indicator.tooltip(context), + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: indicator.color(context), + ), ), - ), + ], const SizedBox(width: 4.0), !loading ? Text( diff --git a/lib/pangea/chat/constants/default_power_level.dart b/lib/pangea/chat/constants/default_power_level.dart index 4bb21ef1b..1459199e6 100644 --- a/lib/pangea/chat/constants/default_power_level.dart +++ b/lib/pangea/chat/constants/default_power_level.dart @@ -1,32 +1,60 @@ Map defaultPowerLevels(String userID) => { + "ban": 50, + "kick": 50, + "invite": 50, + "redact": 50, "events": { - "m.room.avatar": 50, - "m.room.canonical_alias": 50, - "m.room.encryption": 100, - "m.room.history_visibility": 100, - "m.room.name": 50, "m.room.power_levels": 100, - "m.room.server_acl": 100, - "m.room.tombstone": 100, + "m.room.pinned_events": 50, }, + "events_default": 0, + "state_default": 50, "users": { userID: 100, }, + "users_default": 0, + "notifications": { + "room": 50, + }, }; Map restrictedPowerLevels(String userID) => { - "events_default": 50, + "ban": 50, + "kick": 50, + "invite": 50, + "redact": 50, "events": { - "m.room.avatar": 50, - "m.room.canonical_alias": 50, - "m.room.encryption": 100, - "m.room.history_visibility": 100, - "m.room.name": 50, "m.room.power_levels": 100, - "m.room.server_acl": 100, - "m.room.tombstone": 100, + "m.room.pinned_events": 50, }, + "events_default": 50, + "state_default": 50, "users": { userID: 100, }, + "users_default": 0, + "notifications": { + "room": 50, + }, + }; + +Map defaultSpacePowerLevels(String userID) => { + "ban": 50, + "kick": 50, + "invite": 50, + "redact": 50, + "events": { + "m.room.power_levels": 100, + "m.room.join_rules": 100, + "m.space.child": 50, + }, + "events_default": 0, + "state_default": 50, + "users": { + userID: 100, + }, + "users_default": 0, + "notifications": { + "room": 50, + }, }; diff --git a/lib/pangea/chat/widgets/chat_floating_action_button.dart b/lib/pangea/chat/widgets/chat_floating_action_button.dart index a024d5b97..cec4648f0 100644 --- a/lib/pangea/chat/widgets/chat_floating_action_button.dart +++ b/lib/pangea/chat/widgets/chat_floating_action_button.dart @@ -74,7 +74,8 @@ class ChatFloatingActionButtonState extends State { child: const Icon(Icons.arrow_downward_outlined), ); } - if (widget.controller.choreographer.errorService.error != null) { + if (widget.controller.choreographer.errorService.error != null && + !widget.controller.choreographer.itController.willOpen) { return ChoreographerHasErrorButton( widget.controller.choreographer.errorService.error!, widget.controller.choreographer, diff --git a/lib/pangea/chat/widgets/chat_input_bar.dart b/lib/pangea/chat/widgets/chat_input_bar.dart index ae13b985c..37fb3d397 100644 --- a/lib/pangea/chat/widgets/chat_input_bar.dart +++ b/lib/pangea/chat/widgets/chat_input_bar.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:fluffychat/config/themes.dart'; @@ -22,17 +24,27 @@ class ChatInputBar extends StatefulWidget { } class ChatInputBarState extends State { - void updateHeight() { - final renderBox = context.findRenderObject() as RenderBox?; - if (renderBox == null || !renderBox.hasSize) return; - widget.controller.updateInputBarHeight(renderBox.size.height); + Timer? _debounceTimer; + + void _updateHeight() { + _debounceTimer = Timer(const Duration(milliseconds: 100), () { + final renderBox = context.findRenderObject() as RenderBox?; + if (renderBox == null || !renderBox.hasSize) return; + widget.controller.updateInputBarHeight(renderBox.size.height); + }); + } + + @override + void dispose() { + _debounceTimer?.cancel(); + super.dispose(); } @override Widget build(BuildContext context) { return NotificationListener( onNotification: (SizeChangedLayoutNotification notification) { - WidgetsBinding.instance.addPostFrameCallback((_) => updateHeight()); + WidgetsBinding.instance.addPostFrameCallback((_) => _updateHeight()); return true; }, child: SizeChangedLayoutNotifier( diff --git a/lib/pangea/chat_settings/pages/pangea_chat_details.dart b/lib/pangea/chat_settings/pages/pangea_chat_details.dart index a28f3abd7..7c50f9dc1 100644 --- a/lib/pangea/chat_settings/pages/pangea_chat_details.dart +++ b/lib/pangea/chat_settings/pages/pangea_chat_details.dart @@ -342,7 +342,7 @@ class PangeaChatDetailsView extends StatelessWidget { if (room.isSpace && room.isRoomAdmin && kIsWeb) DownloadSpaceAnalyticsButton(space: room), Divider(color: theme.dividerColor, height: 1), - if (room.isRoomAdmin && !room.isSpace) + if (room.ownPowerLevel >= 50 && !room.isSpace) ListTile( title: Text( L10n.of(context).downloadGroupText, @@ -361,7 +361,7 @@ class PangeaChatDetailsView extends StatelessWidget { ), onTap: () => _downloadChat(context), ), - if (room.isRoomAdmin && !room.isSpace) + if (room.ownPowerLevel >= 50 && !room.isSpace) Divider(color: theme.dividerColor, height: 1), if (isGroupChat) ListTile( diff --git a/lib/pangea/choreographer/controllers/choreographer.dart b/lib/pangea/choreographer/controllers/choreographer.dart index da59e978d..ef5364270 100644 --- a/lib/pangea/choreographer/controllers/choreographer.dart +++ b/lib/pangea/choreographer/controllers/choreographer.dart @@ -13,7 +13,6 @@ import 'package:fluffychat/pangea/choreographer/enums/edit_type.dart'; import 'package:fluffychat/pangea/choreographer/models/choreo_record.dart'; import 'package:fluffychat/pangea/choreographer/models/it_step.dart'; import 'package:fluffychat/pangea/choreographer/models/pangea_match_model.dart'; -import 'package:fluffychat/pangea/choreographer/repo/language_detection_repo.dart'; import 'package:fluffychat/pangea/choreographer/utils/input_paste_listener.dart'; import 'package:fluffychat/pangea/choreographer/widgets/igc/pangea_text_controller.dart'; import 'package:fluffychat/pangea/choreographer/widgets/igc/paywall_card.dart'; @@ -21,7 +20,6 @@ import 'package:fluffychat/pangea/common/controllers/pangea_controller.dart'; import 'package:fluffychat/pangea/common/utils/any_state_holder.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/common/utils/overlay.dart'; -import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; import 'package:fluffychat/pangea/events/models/representation_content_model.dart'; import 'package:fluffychat/pangea/events/models/tokens_event_content_model.dart'; import 'package:fluffychat/pangea/events/repo/token_api_models.dart'; @@ -43,7 +41,6 @@ class Choreographer { late ITController itController; late IgcController igc; late ErrorService errorService; - late TtsController tts; bool isFetching = false; int _timesClicked = 0; @@ -64,7 +61,6 @@ class Choreographer { _initialize(); } _initialize() { - tts = TtsController(chatController: chatController); _textController = PangeaTextController(choreographer: this); InputPasteListener(_textController, onPaste); itController = ITController(this); @@ -150,56 +146,57 @@ class Choreographer { ) : null; - final detectionResp = await LanguageDetectionRepo.get( - MatrixState.pangeaController.userController.accessToken, - request: LanguageDetectionRequest( + PangeaMessageTokens? tokensSent; + PangeaRepresentation? originalSent; + try { + TokensResponseModel? res; + if (l1LangCode != null && l2LangCode != null) { + res = await pangeaController.messageData + .getTokens( + repEventId: null, + room: chatController.room, + req: TokensRequestModel( + fullText: currentText, + senderL1: l1LangCode!, + senderL2: l2LangCode!, + ), + ) + .timeout(const Duration(seconds: 10)); + } + + originalSent = PangeaRepresentation( + langCode: res?.detections.firstOrNull?.langCode ?? + LanguageKeys.unknownLanguage, text: currentText, - senderl1: l1LangCode, - senderl2: l2LangCode, - ), - ); - final detections = detectionResp.detections; - final detectedLanguage = - detections.firstOrNull?.langCode ?? LanguageKeys.unknownLanguage; - - final PangeaRepresentation originalSent = PangeaRepresentation( - langCode: detectedLanguage, - text: currentText, - originalSent: true, - originalWritten: originalWritten == null, - ); - - List? res; - if (l1LangCode != null && l2LangCode != null) { - res = await pangeaController.messageData.getTokens( - repEventId: null, - room: chatController.room, - req: TokensRequestModel( - fullText: currentText, - langCode: detectedLanguage, - senderL1: l1LangCode!, - senderL2: l2LangCode!, - ), + originalSent: true, + originalWritten: originalWritten == null, ); + + tokensSent = res != null + ? PangeaMessageTokens( + tokens: res.tokens, + detections: res.detections, + ) + : null; + } catch (e, s) { + ErrorHandler.logError( + e: e, + s: s, + data: { + "currentText": currentText, + "l1LangCode": l1LangCode, + "l2LangCode": l2LangCode, + "choreoRecord": choreoRecord.toJson(), + }, + ); + } finally { + chatController.send( + originalSent: originalSent, + tokensSent: tokensSent, + choreo: choreoRecord, + ); + clear(); } - - final PangeaMessageTokens? tokensSent = res != null - ? PangeaMessageTokens( - tokens: res, - detections: detections, - ) - : null; - - chatController.send( - // originalWritten: originalWritten, - originalSent: originalSent, - tokensSent: tokensSent, - //TODO - save originalwritten tokens - // choreo: applicableChoreo, - choreo: choreoRecord, - ); - - clear(); } _resetDebounceTimer() { @@ -456,11 +453,6 @@ class Choreographer { if (!isNormalizationError) continue; final match = igc.igcTextData!.matches[i]; - choreoRecord.addRecord( - _textController.text, - match: match.copyWith..status = PangeaMatchStatus.automatic, - ); - igc.igcTextData!.acceptReplacement( i, match.match.choices!.indexWhere( @@ -468,6 +460,19 @@ class Choreographer { ), ); + final newMatch = match.copyWith; + newMatch.status = PangeaMatchStatus.automatic; + newMatch.match.length = match.match.choices! + .firstWhere((c) => c.isBestCorrection) + .value + .characters + .length; + + choreoRecord.addRecord( + _textController.text, + match: newMatch, + ); + _textController.setSystemText( igc.igcTextData!.originalInput, EditType.igc, @@ -566,7 +571,7 @@ class Choreographer { _textController.dispose(); _languageStream?.cancel(); stateStream.close(); - tts.dispose(); + TtsController.stop(); } LanguageModel? get l2Lang { diff --git a/lib/pangea/choreographer/controllers/error_service.dart b/lib/pangea/choreographer/controllers/error_service.dart index 94d1c83f3..2021815ff 100644 --- a/lib/pangea/choreographer/controllers/error_service.dart +++ b/lib/pangea/choreographer/controllers/error_service.dart @@ -65,7 +65,17 @@ class ErrorService { return Duration(seconds: coolDownSeconds); } + final List _errorCache = []; + setError(ChoreoError? error, {Duration? duration}) { + if (_errorCache.contains(error?.raw.toString())) { + return; + } + + if (error != null) { + _errorCache.add(error.raw.toString()); + } + _error = error; Future.delayed(duration ?? defaultCooldown, () { clear(); diff --git a/lib/pangea/choreographer/controllers/igc_controller.dart b/lib/pangea/choreographer/controllers/igc_controller.dart index cecf03ea0..8af6dc802 100644 --- a/lib/pangea/choreographer/controllers/igc_controller.dart +++ b/lib/pangea/choreographer/controllers/igc_controller.dart @@ -104,7 +104,9 @@ class IgcController { } final IGCTextData igcTextDataResponse = - await _igcTextDataCache[reqBody.hashCode]!.data; + await _igcTextDataCache[reqBody.hashCode]! + .data + .timeout((const Duration(seconds: 10))); // this will happen when the user changes the input while igc is fetching results if (igcTextDataResponse.originalInput != choreographer.currentText) { @@ -293,6 +295,9 @@ class IgcController { igcTextData = null; spanDataController.clearCache(); spanDataController.dispose(); + MatrixState.pAnyState.closeAllOverlays( + filter: RegExp(r'span_card_overlay_\d+'), + ); } dispose() { diff --git a/lib/pangea/choreographer/controllers/it_controller.dart b/lib/pangea/choreographer/controllers/it_controller.dart index 93fa51623..8dfcca3de 100644 --- a/lib/pangea/choreographer/controllers/it_controller.dart +++ b/lib/pangea/choreographer/controllers/it_controller.dart @@ -57,7 +57,7 @@ class ITController { choreographer.setState(); } - Duration get animationSpeed => const Duration(milliseconds: 500); + Duration get animationSpeed => const Duration(milliseconds: 300); Future initializeIT(ITStartData itStartData) async { _willOpen = true; @@ -136,7 +136,8 @@ class ITController { // During first IT step, next step will not be set if (nextITStep == null) { - final ITResponseModel res = await _customInputTranslation(currentText); + final ITResponseModel res = await _customInputTranslation(currentText) + .timeout(const Duration(seconds: 10)); if (sourceText == null) return; if (res.goldContinuances != null && res.goldContinuances!.isNotEmpty) { diff --git a/lib/pangea/choreographer/models/igc_text_data_model.dart b/lib/pangea/choreographer/models/igc_text_data_model.dart index ac6613b58..3991aa4f7 100644 --- a/lib/pangea/choreographer/models/igc_text_data_model.dart +++ b/lib/pangea/choreographer/models/igc_text_data_model.dart @@ -292,12 +292,45 @@ class IGCTextData { // create a pointer to the current index in the original input // and iterate until the pointer has reached the end of the input int currentIndex = 0; + int loops = 0; + final List addedMatches = []; while (currentIndex < originalInput.characters.length) { + if (loops > 100) { + ErrorHandler.logError( + e: "In constructTokenSpan, infinite loop detected", + data: { + "currentIndex": currentIndex, + "matches": textSpanMatches.map((m) => m.toJson()).toList(), + }, + ); + throw "In constructTokenSpan, infinite loop detected"; + } + // check if the pointer is at a match, and if so, get the index of the match final int matchIndex = matchRanges.indexWhere( (range) => currentIndex >= range[0] && currentIndex < range[1], ); - final bool inMatch = matchIndex != -1; + final bool inMatch = matchIndex != -1 && + !addedMatches.contains( + textSpanMatches[matchIndex], + ); + + if (matchIndex != -1 && + addedMatches.contains( + textSpanMatches[matchIndex], + )) { + ErrorHandler.logError( + e: "In constructTokenSpan, currentIndex is in match that has already been added", + data: { + "currentIndex": currentIndex, + "matchIndex": matchIndex, + "matches": textSpanMatches.map((m) => m.toJson()).toList(), + }, + ); + throw "In constructTokenSpan, currentIndex is in match that has already been added"; + } + + final prevIndex = currentIndex; if (inMatch) { // if the pointer is in a match, then add that match to items @@ -312,13 +345,7 @@ class IGCTextData { final span = originalInput.characters .getRange( match.match.offset, - match.match.offset + - (match.match.choices - ?.firstWhere((c) => c.isBestCorrection) - .value - .characters - .length ?? - match.match.length), + match.match.offset + match.match.length, ) .toString(); @@ -364,12 +391,8 @@ class IGCTextData { ), ); - currentIndex = match.match.offset + - (match.match.choices - ?.firstWhere((c) => c.isBestCorrection) - .value - .length ?? - match.match.length); + addedMatches.add(match); + currentIndex = match.match.offset + match.match.length; } else { items.add( getSpanItem( @@ -400,6 +423,20 @@ class IGCTextData { ); currentIndex = nextIndex; } + + if (prevIndex >= currentIndex) { + ErrorHandler.logError( + e: "In constructTokenSpan, currentIndex is less than prevIndex", + data: { + "currentIndex": currentIndex, + "prevIndex": prevIndex, + "matches": textSpanMatches.map((m) => m.toJson()).toList(), + }, + ); + throw "In constructTokenSpan, currentIndex is less than prevIndex"; + } + + loops++; } return items; diff --git a/lib/pangea/choreographer/widgets/choice_array.dart b/lib/pangea/choreographer/widgets/choice_array.dart index 36be9c2b1..8b26e0e5a 100644 --- a/lib/pangea/choreographer/widgets/choice_array.dart +++ b/lib/pangea/choreographer/widgets/choice_array.dart @@ -29,10 +29,6 @@ class ChoicesArray extends StatefulWidget { final int? selectedChoiceIndex; final String originalSpan; - /// If null then should not be used - /// We don't want tts in the case of L1 options - final TtsController? tts; - final bool enableAudio; /// language code for the TTS @@ -62,7 +58,6 @@ class ChoicesArray extends StatefulWidget { required this.onPressed, required this.originalSpan, required this.selectedChoiceIndex, - required this.tts, this.enableAudio = true, this.langCode, this.isActive = true, @@ -111,10 +106,8 @@ class ChoicesArrayState extends State { ? (String value, int index) { widget.onPressed(value, index); // TODO - what to pass here as eventID? - if (widget.enableAudio && - widget.tts != null && - widget.langCode != null) { - widget.tts?.tryToSpeak( + if (widget.enableAudio && widget.langCode != null) { + TtsController.tryToSpeak( value, targetID: null, langCode: widget.langCode!, diff --git a/lib/pangea/choreographer/widgets/igc/pangea_text_controller.dart b/lib/pangea/choreographer/widgets/igc/pangea_text_controller.dart index d887eebbf..50ad90e68 100644 --- a/lib/pangea/choreographer/widgets/igc/pangea_text_controller.dart +++ b/lib/pangea/choreographer/widgets/igc/pangea_text_controller.dart @@ -3,6 +3,7 @@ import 'dart:developer'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:fluffychat/pangea/choreographer/controllers/error_service.dart'; import 'package:fluffychat/pangea/choreographer/models/igc_text_data_model.dart'; import 'package:fluffychat/pangea/choreographer/models/pangea_match_model.dart'; import 'package:fluffychat/pangea/choreographer/widgets/igc/paywall_card.dart'; @@ -174,18 +175,29 @@ class PangeaTextController extends TextEditingController { final choreoSteps = choreographer.choreoRecord.choreoSteps; + List inlineSpans = []; + try { + inlineSpans = choreographer.igc.igcTextData!.constructTokenSpan( + choreoSteps: choreoSteps.isNotEmpty && + choreoSteps.last.acceptedOrIgnoredMatch?.status == + PangeaMatchStatus.automatic + ? choreoSteps + : [], + defaultStyle: style, + onUndo: choreographer.onUndoReplacement, + ); + } catch (e) { + choreographer.errorService.setError( + ChoreoError(type: ChoreoErrorType.unknown, raw: e), + ); + inlineSpans = [TextSpan(text: text, style: style)]; + choreographer.igc.clear(); + } + return TextSpan( style: style, children: [ - ...choreographer.igc.igcTextData!.constructTokenSpan( - choreoSteps: choreoSteps.isNotEmpty && - choreoSteps.last.acceptedOrIgnoredMatch?.status == - PangeaMatchStatus.automatic - ? choreoSteps - : [], - defaultStyle: style, - onUndo: choreographer.onUndoReplacement, - ), + ...inlineSpans, TextSpan(text: parts[1], style: style), ], ); diff --git a/lib/pangea/choreographer/widgets/igc/span_card.dart b/lib/pangea/choreographer/widgets/igc/span_card.dart index 0fd6b5bb1..f5662f3ba 100644 --- a/lib/pangea/choreographer/widgets/igc/span_card.dart +++ b/lib/pangea/choreographer/widgets/igc/span_card.dart @@ -60,12 +60,10 @@ class SpanCardState extends State { @override void dispose() { - tts.stop(); + TtsController.stop(); super.dispose(); } - TtsController get tts => widget.scm.choreographer.tts; - //get selected choice SpanChoice? get selectedChoice { if (selectedChoiceIndex == null) return null; @@ -263,7 +261,6 @@ class WordMatchContent extends StatelessWidget { onPressed: (value, index) => controller.onChoiceSelect(index), selectedChoiceIndex: controller.selectedChoiceIndex, - tts: controller.tts, id: controller.widget.scm.pangeaMatch!.hashCode .toString(), langCode: MatrixState.pangeaController.languageController diff --git a/lib/pangea/choreographer/widgets/it_bar.dart b/lib/pangea/choreographer/widgets/it_bar.dart index 6f02ade8e..b5b704d08 100644 --- a/lib/pangea/choreographer/widgets/it_bar.dart +++ b/lib/pangea/choreographer/widgets/it_bar.dart @@ -7,7 +7,6 @@ import 'package:flutter/material.dart'; import 'package:fluffychat/pangea/choreographer/constants/choreo_constants.dart'; import 'package:fluffychat/pangea/choreographer/controllers/choreographer.dart'; import 'package:fluffychat/pangea/choreographer/controllers/it_controller.dart'; -import 'package:fluffychat/pangea/choreographer/widgets/it_bar_buttons.dart'; import 'package:fluffychat/pangea/choreographer/widgets/it_feedback_card.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/instructions/instructions_enum.dart'; @@ -116,9 +115,13 @@ class ITBarState extends State with SingleTickerProviderStateMixin { Container( key: widget.choreographer.itBarLinkAndKey.key, decoration: BoxDecoration( + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(24), + topRight: Radius.circular(24), + ), color: Theme.of(context).colorScheme.surfaceContainer, ), - padding: const EdgeInsets.fromLTRB(0, 3, 3, 3), + padding: const EdgeInsets.all(3), child: SingleChildScrollView( child: Column( children: [ @@ -202,12 +205,14 @@ class ITBarState extends State with SingleTickerProviderStateMixin { if (!itController.isEditingSourceText) Padding( padding: const EdgeInsets.only(top: 8.0), - child: itController.sourceText != null - ? Text( - itController.sourceText!, - textAlign: TextAlign.center, - ) - : const LinearProgressIndicator(), + child: !itController.willOpen + ? const SizedBox() + : itController.sourceText != null + ? Text( + itController.sourceText!, + textAlign: TextAlign.center, + ) + : const LinearProgressIndicator(), ), const SizedBox(height: 8.0), Container( @@ -387,10 +392,12 @@ class ITChoices extends StatelessWidget { return const SizedBox(); } if (controller.currentITStep == null) { - return CircularProgressIndicator( - strokeWidth: 2.0, - color: Theme.of(context).colorScheme.primary, - ); + return controller.willOpen + ? CircularProgressIndicator( + strokeWidth: 2.0, + color: Theme.of(context).colorScheme.primary, + ) + : const SizedBox(); } return ChoicesArray( id: controller.currentITStep.hashCode.toString(), @@ -414,7 +421,6 @@ class ITChoices extends StatelessWidget { onPressed: (value, index) => selectContinuance(index, context), onLongPress: (value, index) => showCard(context, index), selectedChoiceIndex: null, - tts: controller.choreographer.tts, langCode: controller.choreographer.pangeaController.languageController .activeL2Code(), ); @@ -435,23 +441,38 @@ class ITError extends StatelessWidget { final ErrorCopy errorCopy = ErrorCopy(context, error); return Padding( padding: const EdgeInsets.symmetric(horizontal: 8), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 300), - child: Text( - // Text( - "${errorCopy.title}\n${errorCopy.body}", - // Haga clic en su mensaje para ver los significados de las palabras. - style: TextStyle( - fontStyle: FontStyle.italic, + child: RichText( + text: TextSpan( + children: [ + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Icon( + Icons.error_outline, + size: 20, color: Theme.of(context).colorScheme.error, ), ), + TextSpan(text: " ${errorCopy.title} "), + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: IconButton( + onPressed: () { + controller.closeIT(); + controller.choreographer.errorService.resetError(); + }, + icon: const Icon( + Icons.close, + size: 20, + ), + ), + ), + ], + style: TextStyle( + fontStyle: FontStyle.italic, + color: Theme.of(context).colorScheme.error, ), - ITRestartButton(controller: controller), - ], + ), + textAlign: TextAlign.center, ), ); } diff --git a/lib/pangea/choreographer/widgets/it_bar_buttons.dart b/lib/pangea/choreographer/widgets/it_bar_buttons.dart deleted file mode 100644 index bcc7704d7..000000000 --- a/lib/pangea/choreographer/widgets/it_bar_buttons.dart +++ /dev/null @@ -1,27 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:fluffychat/pangea/common/controllers/pangea_controller.dart'; -import 'package:fluffychat/widgets/matrix.dart'; -import '../controllers/it_controller.dart'; - -class ITRestartButton extends StatelessWidget { - ITRestartButton({ - super.key, - required this.controller, - }); - - final ITController controller; - final PangeaController pangeaController = MatrixState.pangeaController; - - @override - Widget build(BuildContext context) { - return IconButton( - onPressed: () async { - controller.choreographer.errorService.resetError(); - controller.currentITStep = null; - controller.choreographer.getLanguageHelp(); - }, - icon: const Icon(Icons.refresh_outlined), - ); - } -} diff --git a/lib/pangea/common/controllers/pangea_controller.dart b/lib/pangea/common/controllers/pangea_controller.dart index 7d67a074f..5018d6b17 100644 --- a/lib/pangea/common/controllers/pangea_controller.dart +++ b/lib/pangea/common/controllers/pangea_controller.dart @@ -11,6 +11,7 @@ import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:fluffychat/pangea/analytics_misc/get_analytics_controller.dart'; import 'package:fluffychat/pangea/analytics_misc/put_analytics_controller.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/choreographer/controllers/contextual_definition_controller.dart'; @@ -268,6 +269,13 @@ class PangeaController { preset: CreateRoomPreset.trustedPrivateChat, initialState: [ BotOptionsModel(mode: BotMode.directChat).toStateEvent, + StateEvent( + type: EventTypes.RoomPowerLevels, + stateKey: '', + content: defaultPowerLevels( + matrixState.client.userID!, + ), + ), ], ); diff --git a/lib/pangea/common/widgets/full_width_dialog.dart b/lib/pangea/common/widgets/full_width_dialog.dart index e3f93ffc6..c95060407 100644 --- a/lib/pangea/common/widgets/full_width_dialog.dart +++ b/lib/pangea/common/widgets/full_width_dialog.dart @@ -1,3 +1,5 @@ +import 'dart:ui'; + import 'package:flutter/material.dart'; import 'package:fluffychat/config/themes.dart'; @@ -16,24 +18,32 @@ class FullWidthDialog extends StatelessWidget { @override Widget build(BuildContext context) { - final content = ConstrainedBox( - constraints: FluffyThemes.isColumnMode(context) - ? BoxConstraints( - maxWidth: maxWidth, - maxHeight: maxHeight, - ) - : BoxConstraints( - maxWidth: MediaQuery.of(context).size.width, - maxHeight: MediaQuery.of(context).size.height, - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(20.0), - child: dialogContent, + final isColumnMode = FluffyThemes.isColumnMode(context); + final content = AnimatedSize( + duration: FluffyThemes.animationDuration, + child: ConstrainedBox( + constraints: isColumnMode + ? BoxConstraints( + maxWidth: maxWidth, + maxHeight: maxHeight, + ) + : BoxConstraints( + maxWidth: MediaQuery.of(context).size.width, + maxHeight: MediaQuery.of(context).size.height, + ), + child: ClipRRect( + borderRadius: + isColumnMode ? BorderRadius.circular(20.0) : BorderRadius.zero, + child: dialogContent, + ), ), ); - return FluffyThemes.isColumnMode(context) - ? Dialog(child: content) - : Dialog.fullscreen(child: content); + return BackdropFilter( + filter: ImageFilter.blur(sigmaX: 2.5, sigmaY: 2.5), + child: isColumnMode + ? Dialog(child: content) + : Dialog.fullscreen(child: content), + ); } } diff --git a/lib/pangea/events/controllers/message_data_controller.dart b/lib/pangea/events/controllers/message_data_controller.dart index 1421b187d..bfdee9313 100644 --- a/lib/pangea/events/controllers/message_data_controller.dart +++ b/lib/pangea/events/controllers/message_data_controller.dart @@ -12,7 +12,6 @@ import 'package:fluffychat/pangea/common/controllers/pangea_controller.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'; -import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; import 'package:fluffychat/pangea/events/models/representation_content_model.dart'; import 'package:fluffychat/pangea/events/models/tokens_event_content_model.dart'; import 'package:fluffychat/pangea/events/repo/token_api_models.dart'; @@ -23,7 +22,7 @@ import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; class MessageDataController extends BaseController { late PangeaController _pangeaController; - final Map>> _tokensCache = {}; + final Map> _tokensCache = {}; final Map> _representationCache = {}; late Timer _cacheTimer; @@ -54,7 +53,7 @@ class MessageDataController extends BaseController { /// get tokens from the server /// if repEventId is not null, send the tokens to the room - Future> _getTokens({ + Future _getTokens({ required String? repEventId, required TokensRequestModel req, required Room? room, @@ -83,13 +82,13 @@ class MessageDataController extends BaseController { ); } - return res.tokens; + return res; } /// get tokens from the server /// first check if the tokens are in the cache /// if repEventId is not null, send the tokens to the room - Future> getTokens({ + Future getTokens({ required String? repEventId, required TokensRequestModel req, required Room? room, diff --git a/lib/pangea/events/event_wrappers/pangea_representation_event.dart b/lib/pangea/events/event_wrappers/pangea_representation_event.dart index 27431695b..b7607e36e 100644 --- a/lib/pangea/events/event_wrappers/pangea_representation_event.dart +++ b/lib/pangea/events/event_wrappers/pangea_representation_event.dart @@ -93,7 +93,7 @@ class RepresentationEvent { if (tokenEvents.isEmpty) return null; if (tokenEvents.length > 1) { - debugger(when: kDebugMode); + // debugger(when: kDebugMode); Sentry.addBreadcrumb( Breadcrumb( message: @@ -162,7 +162,7 @@ class RepresentationEvent { ), ); } - final List res = + final TokensResponseModel res = await MatrixState.pangeaController.messageData.getTokens( repEventId: _event?.eventId, room: _event?.room ?? parentMessageEvent.room, @@ -180,7 +180,7 @@ class RepresentationEvent { ), ); - return res; + return res.tokens; } Future sendTokensEvent( diff --git a/lib/pangea/events/repo/token_api_models.dart b/lib/pangea/events/repo/token_api_models.dart index cbd0035b5..2c3744454 100644 --- a/lib/pangea/events/repo/token_api_models.dart +++ b/lib/pangea/events/repo/token_api_models.dart @@ -1,6 +1,7 @@ import 'package:fluffychat/pangea/choreographer/models/language_detection_model.dart'; import 'package:fluffychat/pangea/common/constants/model_keys.dart'; import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; +import 'package:fluffychat/pangea/learning_settings/constants/language_constants.dart'; class TokensRequestModel { /// the text to be tokenized @@ -24,16 +25,16 @@ class TokensRequestModel { TokensRequestModel({ required this.fullText, - required this.langCode, required this.senderL1, required this.senderL2, + this.langCode, }); Map toJson() => { ModelKey.fullText: fullText, ModelKey.userL1: senderL1, ModelKey.userL2: senderL2, - ModelKey.langCode: langCode, + ModelKey.langCode: langCode ?? LanguageKeys.unknownLanguage, }; // override equals and hashcode diff --git a/lib/pangea/events/utils/message_text_util.dart b/lib/pangea/events/utils/message_text_util.dart index edc2c2544..25c18c035 100644 --- a/lib/pangea/events/utils/message_text_util.dart +++ b/lib/pangea/events/utils/message_text_util.dart @@ -49,8 +49,12 @@ class MessageTextUtil { return null; } - if (_tokenPositionsCache.containsKey(pangeaMessageEvent.eventId)) { - return _tokenPositionsCache[pangeaMessageEvent.eventId]! + final cacheKey = pangeaMessageEvent.event + .getDisplayEvent(pangeaMessageEvent.timeline) + .eventId; + + if (_tokenPositionsCache.containsKey(cacheKey)) { + return _tokenPositionsCache[cacheKey]! .map( (t) => TokenPosition( start: t.start, @@ -154,7 +158,7 @@ class MessageTextUtil { continue; } - _tokenPositionsCache[pangeaMessageEvent.eventId] = tokenPositions; + _tokenPositionsCache[cacheKey] = tokenPositions; return tokenPositions; } catch (err, s) { diff --git a/lib/pangea/extensions/room_events_extension.dart b/lib/pangea/extensions/room_events_extension.dart index 626b768f3..c72044a54 100644 --- a/lib/pangea/extensions/room_events_extension.dart +++ b/lib/pangea/extensions/room_events_extension.dart @@ -274,10 +274,20 @@ extension EventsRoomExtension on Room { }) async { Uint8List? bytes = avatar; if (avatarURL != null && bytes == null) { - final resp = await http - .get(Uri.parse(avatarURL)) - .timeout(const Duration(seconds: 5)); - bytes = resp.bodyBytes; + try { + final resp = await http + .get(Uri.parse(avatarURL)) + .timeout(const Duration(seconds: 5)); + bytes = resp.bodyBytes; + } catch (e, s) { + ErrorHandler.logError( + e: e, + s: s, + data: { + "avatarURL": avatarURL, + }, + ); + } } MatrixFile? file; diff --git a/lib/pangea/layouts/bottom_nav_layout.dart b/lib/pangea/layouts/bottom_nav_layout.dart deleted file mode 100644 index 4bd7ee99a..000000000 --- a/lib/pangea/layouts/bottom_nav_layout.dart +++ /dev/null @@ -1,96 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:flutter_gen/gen_l10n/l10n.dart'; -import 'package:go_router/go_router.dart'; - -class BottomNavLayout extends StatelessWidget { - final Widget mainView; - - const BottomNavLayout({ - super.key, - required this.mainView, - }); - @override - Widget build(BuildContext context) { - return Scaffold( - body: mainView, - bottomNavigationBar: const BottomNavBar(), - ); - } -} - -class BottomNavBar extends StatefulWidget { - const BottomNavBar({ - super.key, - }); - - @override - BottomNavBarState createState() => BottomNavBarState(); -} - -class BottomNavBarState extends State { - int get selectedIndex { - final route = GoRouterState.of(context).fullPath.toString(); - if (route.contains("settings")) { - return 2; - } - if (route.contains('homepage')) { - return 0; - } - return 1; - } - - void onItemTapped(int index) { - switch (index) { - case 0: - context.go('/homepage'); - break; - case 1: - context.go('/rooms'); - break; - case 2: - context.go('/rooms/settings'); - break; - } - - if (mounted) setState(() {}); - } - - @override - Widget build(BuildContext context) { - return Container( - decoration: BoxDecoration( - border: Border( - top: BorderSide( - color: Theme.of(context).colorScheme.primary.withAlpha(50), - ), - ), - ), - child: BottomNavigationBar( - iconSize: 16.0, - onTap: onItemTapped, - selectedItemColor: Theme.of(context).colorScheme.primary, - selectedFontSize: 14.0, - unselectedFontSize: 14.0, - currentIndex: selectedIndex, - items: [ - BottomNavigationBarItem( - icon: const Icon(Icons.home_outlined), - activeIcon: const Icon(Icons.home), - label: L10n.of(context).home, - ), - BottomNavigationBarItem( - icon: const Icon(Icons.chat_bubble_outline), - activeIcon: const Icon(Icons.chat_bubble), - label: L10n.of(context).chats, - ), - BottomNavigationBarItem( - icon: const Icon(Icons.settings_outlined), - activeIcon: const Icon(Icons.settings), - label: L10n.of(context).settings, - ), - ], - ), - ); - } -} diff --git a/lib/pangea/learning_settings/pages/settings_learning.dart b/lib/pangea/learning_settings/pages/settings_learning.dart index 92845f6d4..e099b1f10 100644 --- a/lib/pangea/learning_settings/pages/settings_learning.dart +++ b/lib/pangea/learning_settings/pages/settings_learning.dart @@ -35,7 +35,6 @@ class SettingsLearning extends StatefulWidget { class SettingsLearningController extends State { PangeaController pangeaController = MatrixState.pangeaController; late Profile _profile; - final tts = TtsController(); final GlobalKey formKey = GlobalKey(); String? languageMatchError; @@ -46,12 +45,12 @@ class SettingsLearningController extends State { void initState() { super.initState(); _profile = pangeaController.userController.profile.copy(); - tts.setAvailableLanguages().then((_) => setState(() {})); + TtsController.setAvailableLanguages().then((_) => setState(() {})); } @override void dispose() { - tts.dispose(); + TtsController.stop(); scrollController.dispose(); super.dispose(); } diff --git a/lib/pangea/login/pages/user_settings.dart b/lib/pangea/login/pages/user_settings.dart index b403f81ff..0719fe5d7 100644 --- a/lib/pangea/login/pages/user_settings.dart +++ b/lib/pangea/login/pages/user_settings.dart @@ -259,8 +259,9 @@ class UserSettingsState extends State { MatrixState.pangeaController.pLanguageStore.baseOptions; bool get _hasIdenticalLanguages => - _systemLanguage != null && - _systemLanguage?.langCodeShort == selectedTargetLanguage?.langCodeShort; + selectedBaseLanguage != null && + selectedTargetLanguage?.langCodeShort == + selectedBaseLanguage?.langCodeShort; @override Widget build(BuildContext context) => UserSettingsView(controller: this); diff --git a/lib/pangea/practice_activities/activity_type_enum.dart b/lib/pangea/practice_activities/activity_type_enum.dart index 51e8ae106..2fe80c418 100644 --- a/lib/pangea/practice_activities/activity_type_enum.dart +++ b/lib/pangea/practice_activities/activity_type_enum.dart @@ -15,25 +15,6 @@ enum ActivityTypeEnum { } extension ActivityTypeExtension on ActivityTypeEnum { - String get string { - switch (this) { - case ActivityTypeEnum.wordMeaning: - return 'word_meaning'; - case ActivityTypeEnum.wordFocusListening: - return 'word_focus_listening'; - case ActivityTypeEnum.hiddenWordListening: - return 'hidden_word_listening'; - case ActivityTypeEnum.lemmaId: - return 'lemma_id'; - case ActivityTypeEnum.emoji: - return 'emoji'; - case ActivityTypeEnum.morphId: - return 'morph_id'; - case ActivityTypeEnum.messageMeaning: - return 'message_meaning'; // TODO: Add to L10n - } - } - bool get hiddenType { switch (this) { case ActivityTypeEnum.wordMeaning: diff --git a/lib/pangea/practice_activities/message_activity_request.dart b/lib/pangea/practice_activities/message_activity_request.dart index 78907d196..f3427514e 100644 --- a/lib/pangea/practice_activities/message_activity_request.dart +++ b/lib/pangea/practice_activities/message_activity_request.dart @@ -83,7 +83,7 @@ class MessageActivityRequest { 'message_tokens': messageTokens.map((e) => e.toJson()).toList(), 'activity_quality_feedback': activityQualityFeedback?.toJson(), 'target_tokens': targetTokens.map((e) => e.toJson()).toList(), - 'target_type': targetType.string, + 'target_type': targetType.name, 'target_morph_feature': targetMorphFeature, }; } diff --git a/lib/pangea/practice_activities/practice_activity_model.dart b/lib/pangea/practice_activities/practice_activity_model.dart index 5dee155aa..68a0ce6b0 100644 --- a/lib/pangea/practice_activities/practice_activity_model.dart +++ b/lib/pangea/practice_activities/practice_activity_model.dart @@ -326,7 +326,7 @@ class PracticeActivityModel { Map toJson() { return { 'lang_code': langCode, - 'activity_type': activityType.string, + 'activity_type': activityType.name, 'content': multipleChoiceContent?.toJson(), 'target_tokens': targetTokens.map((e) => e.toJson()).toList(), 'match_content': matchContent?.toJson(), diff --git a/lib/pangea/practice_activities/practice_record_repo.dart b/lib/pangea/practice_activities/practice_record_repo.dart index 28394a7c9..b6a84e342 100644 --- a/lib/pangea/practice_activities/practice_record_repo.dart +++ b/lib/pangea/practice_activities/practice_record_repo.dart @@ -26,7 +26,7 @@ class PracticeRecordRepo { } static void clean() { - final Iterable keys = _storage.getKeys(); + final keys = _storage.getKeys(); if (keys.length > 300) { final entries = keys .map((key) { diff --git a/lib/pangea/practice_activities/practice_target.dart b/lib/pangea/practice_activities/practice_target.dart index df034d20b..fbb711f2a 100644 --- a/lib/pangea/practice_activities/practice_target.dart +++ b/lib/pangea/practice_activities/practice_target.dart @@ -2,6 +2,8 @@ import 'dart:developer'; import 'package:flutter/foundation.dart'; +import 'package:collection/collection.dart'; + import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; @@ -60,13 +62,22 @@ class PracticeTarget { userL2.hashCode; static PracticeTarget fromJson(Map json) { + final type = ActivityTypeEnum.values.firstWhereOrNull( + (v) => json['activityType'] == v.name, + ); + if (type == null) { + throw Exception( + "ActivityTypeEnum ${json['activityType']} not found in enum", + ); + } + return PracticeTarget( tokens: (json['tokens'] as List).map((e) => PangeaToken.fromJson(e)).toList(), - activityType: ActivityTypeEnum.values[json['activityType']], + activityType: type, morphFeature: json['morphFeature'] == null ? null - : MorphFeaturesEnum.values[json['morphFeature']], + : MorphFeaturesEnumExtension.fromString(json['morphFeature']), userL2: json['userL2'], ); } @@ -83,7 +94,7 @@ class PracticeTarget { //unique condensed deterministic key for local storage String get storageKey { return tokens.map((e) => e.text.content).join() + - activityType.string + + activityType.name + (morphFeature?.name ?? ""); } diff --git a/lib/pangea/public_spaces/public_room_bottom_sheet.dart b/lib/pangea/public_spaces/public_room_bottom_sheet.dart new file mode 100644 index 000000000..2a5381fc4 --- /dev/null +++ b/lib/pangea/public_spaces/public_room_bottom_sheet.dart @@ -0,0 +1,395 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:go_router/go_router.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/pangea/common/config/environment.dart'; +import 'package:fluffychat/utils/adaptive_bottom_sheet.dart'; +import 'package:fluffychat/utils/fluffy_share.dart'; +import 'package:fluffychat/widgets/avatar.dart'; +import 'package:fluffychat/widgets/future_loading_dialog.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +class PublicRoomBottomSheet extends StatefulWidget { + final String? roomAlias; + final BuildContext outerContext; + final PublicRoomsChunk? chunk; + final List? via; + + PublicRoomBottomSheet({ + this.roomAlias, + required this.outerContext, + this.chunk, + this.via, + super.key, + }) { + assert(roomAlias != null || chunk != null); + } + + static Future show({ + required BuildContext context, + String? roomAlias, + PublicRoomsChunk? chunk, + List? via, + }) async { + final room = MatrixState.pangeaController.matrixState.client + .getRoomById(chunk!.roomId); + + if (room != null && room.membership == Membership.join) { + context.go("/rooms?spaceId=${room.id}"); + return null; + } + + return showAdaptiveBottomSheet( + context: context, + builder: (context) => PublicRoomBottomSheet( + roomAlias: roomAlias, + chunk: chunk, + via: via, + outerContext: context, + ), + ); + } + + @override + State createState() => PublicRoomBottomSheetState(); +} + +class PublicRoomBottomSheetState extends State { + BuildContext get outerContext => widget.outerContext; + PublicRoomsChunk? get chunk => widget.chunk; + String? get roomAlias => widget.roomAlias; + List? get via => widget.via; + + final TextEditingController _codeController = TextEditingController(); + late Client client; + + @override + void initState() { + super.initState(); + client = Matrix.of(outerContext).client; + } + + @override + void dispose() { + _codeController.dispose(); + super.dispose(); + } + + Room? get room => client.getRoomById(chunk!.roomId); + bool get _isRoomMember => room != null && room!.membership == Membership.join; + bool get _isKnockRoom => widget.chunk?.joinRule == 'knock'; + + Future _joinWithCode() async { + final resp = + await MatrixState.pangeaController.classController.joinClasswithCode( + context, + _codeController.text, + notFoundError: L10n.of(context).notTheCodeError, + ); + if (!resp.isError) { + Navigator.of(context).pop(true); + } + } + + void _goToRoom(String roomID) { + if (chunk?.roomType != 'm.space' && !client.getRoomById(roomID)!.isSpace) { + outerContext.go("/rooms/$roomID"); + } else { + context.go('/rooms?spaceId=$roomID'); + } + } + + Future _joinRoom() async { + if (_isRoomMember) { + _goToRoom(room!.id); + Navigator.of(context).pop(); + return; + } + + final result = await showFutureLoadingDialog( + context: context, + future: () async { + final roomId = await client.joinRoom( + roomAlias ?? chunk!.roomId, + serverName: via, + ); + + final room = client.getRoomById(roomId); + if (room == null || room.membership != Membership.join) { + await client.waitForRoomInSync(roomId, join: true); + } + return roomId; + }, + ); + + if (result.result != null) { + _goToRoom(result.result!); + Navigator.of(context).pop(true); + } + } + + Future _knockRoom() async { + if (_isRoomMember) { + _goToRoom(room!.id); + Navigator.of(context).pop(); + return; + } + + await showFutureLoadingDialog( + context: context, + future: () async => client.knockRoom( + roomAlias ?? chunk!.roomId, + serverName: via, + ), + onSuccess: () => L10n.of(context).knockSpaceSuccess, + delay: false, + ); + } + + bool testRoom(PublicRoomsChunk r) => r.canonicalAlias == roomAlias; + + Future search() async { + final chunk = this.chunk; + if (chunk != null) return chunk; + final query = await Matrix.of(outerContext).client.queryPublicRooms( + server: roomAlias!.domain, + filter: PublicRoomQueryFilter( + genericSearchTerm: roomAlias, + ), + ); + if (!query.chunk.any(testRoom)) { + throw (L10n.of(outerContext).noRoomsFound); + } + return query.chunk.firstWhere(testRoom); + } + + @override + Widget build(BuildContext context) { + final roomAlias = this.roomAlias ?? chunk?.canonicalAlias; + return SafeArea( + child: Scaffold( + appBar: AppBar( + automaticallyImplyLeading: false, + title: Text( + chunk?.name ?? roomAlias ?? chunk?.roomId ?? 'Unknown', + overflow: TextOverflow.fade, + ), + actions: [ + Center( + child: CloseButton( + onPressed: Navigator.of(context, rootNavigator: false).pop, + ), + ), + ], + ), + body: FutureBuilder( + future: search(), + builder: (context, snapshot) { + return Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + spacing: 32.0, + children: [ + Row( + spacing: 16.0, + children: [ + Avatar( + mxContent: chunk?.avatarUrl, + name: chunk?.name, + size: 160.0, + borderRadius: BorderRadius.circular(24.0), + ), + Expanded( + child: SizedBox( + height: 160.0, + child: Column( + spacing: 16.0, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + spacing: 8.0, + children: [ + const Icon(Icons.group), + Text( + L10n.of(context).countParticipants( + chunk?.numJoinedMembers ?? 1, + ), + ), + ], + ), + if (chunk?.topic != null) + Flexible( + child: SingleChildScrollView( + child: Text( + chunk!.topic!, + softWrap: true, + textAlign: TextAlign.start, + maxLines: null, + ), + ), + ), + ], + ), + ), + ), + ], + ), + Column( + spacing: 8.0, + children: _isKnockRoom + ? [ + Container( + decoration: BoxDecoration( + border: Border.all( + color: Theme.of(context).colorScheme.outline, + ), + borderRadius: BorderRadius.circular(24), + ), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _codeController, + decoration: InputDecoration( + border: InputBorder.none, + focusedBorder: InputBorder.none, + enabledBorder: InputBorder.none, + errorBorder: InputBorder.none, + disabledBorder: InputBorder.none, + hintText: + L10n.of(context).enterSpaceCode, + contentPadding: + const EdgeInsets.symmetric( + horizontal: 16.0, + ), + hintStyle: TextStyle( + color: Theme.of(context).hintColor, + ), + ), + ), + ), + Container( + decoration: BoxDecoration( + border: Border( + left: BorderSide( + color: Theme.of(context) + .colorScheme + .outline, + ), + ), + ), + child: ElevatedButton( + onPressed: _joinWithCode, + style: ElevatedButton.styleFrom( + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.zero, + bottomLeft: Radius.zero, + topRight: Radius.circular(24), + bottomRight: Radius.circular(24), + ), + ), + ), + child: Text(L10n.of(context).join), + ), + ), + ], + ), + ), + ElevatedButton( + onPressed: _knockRoom, + child: Row( + spacing: 8.0, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Symbols.door_open, + size: 20.0, + ), + Text(L10n.of(context).askToJoin), + ], + ), + ), + if (roomAlias != null) + ElevatedButton( + onPressed: () { + FluffyShare.share( + "${Environment.frontendURL}/#/join_with_alias?alias=${Uri.encodeComponent(roomAlias)}", + context, + ); + Navigator.of(context).pop(); + }, + child: Row( + spacing: 8.0, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.share_outlined, + size: 20.0, + ), + Flexible( + child: Text( + L10n.of(context).shareSpaceLink, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ] + : [ + ElevatedButton( + onPressed: _joinRoom, + child: Row( + spacing: 8.0, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.join_full_outlined, + size: 20.0, + ), + Text(L10n.of(context).join), + ], + ), + ), + if (roomAlias != null) + ElevatedButton( + onPressed: () { + FluffyShare.share( + "${Environment.frontendURL}/#/join_with_alias?alias=${Uri.encodeComponent(roomAlias)}", + context, + ); + Navigator.of(context).pop(); + }, + child: Row( + spacing: 8.0, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.share_outlined, + size: 20.0, + ), + Flexible( + child: Text( + L10n.of(context).shareSpaceLink, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ], + ), + ], + ), + ); + }, + ), + ), + ); + } +} diff --git a/lib/pangea/public_spaces/public_space_card.dart b/lib/pangea/public_spaces/public_space_card.dart index 47c55961c..8d1c58ddf 100644 --- a/lib/pangea/public_spaces/public_space_card.dart +++ b/lib/pangea/public_spaces/public_space_card.dart @@ -4,7 +4,7 @@ import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:matrix/matrix.dart'; import 'package:fluffychat/pangea/common/widgets/pressable_button.dart'; -import 'package:fluffychat/widgets/adaptive_dialogs/public_room_dialog.dart'; +import 'package:fluffychat/pangea/public_spaces/public_room_bottom_sheet.dart'; import 'package:fluffychat/widgets/mxc_image.dart'; class PublicSpaceCard extends StatelessWidget { @@ -24,10 +24,10 @@ class PublicSpaceCard extends StatelessWidget { final theme = Theme.of(context); return PressableButton( - onPressed: () => PublicRoomDialog.show( - context: context, + onPressed: () => PublicRoomBottomSheet.show( roomAlias: space.canonicalAlias ?? space.roomId, chunk: space, + context: context, ), borderRadius: BorderRadius.circular(24.0), color: theme.brightness == Brightness.dark @@ -74,7 +74,7 @@ class PublicSpaceCard extends StatelessWidget { padding: const EdgeInsets.all(8.0), child: Column( spacing: 4.0, - mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, children: [ Column( diff --git a/lib/pangea/public_spaces/public_spaces_area.dart b/lib/pangea/public_spaces/public_spaces_area.dart index 36b0e5a60..645f321a4 100644 --- a/lib/pangea/public_spaces/public_spaces_area.dart +++ b/lib/pangea/public_spaces/public_spaces_area.dart @@ -176,7 +176,7 @@ class PublicSpacesAreaState extends State { key: const ValueKey('title'), children: [ Text( - L10n.of(context).publicSpacesTitle, + L10n.of(context).findYourPeople, style: isColumnMode ? theme.textTheme.titleLarge ?.copyWith(fontWeight: FontWeight.bold) diff --git a/lib/pangea/spaces/utils/client_spaces_extension.dart b/lib/pangea/spaces/utils/client_spaces_extension.dart index 8f6f6e759..fe1345768 100644 --- a/lib/pangea/spaces/utils/client_spaces_extension.dart +++ b/lib/pangea/spaces/utils/client_spaces_extension.dart @@ -11,6 +11,8 @@ import 'package:fluffychat/pangea/spaces/utils/space_code.dart'; extension SpacesClientExtension on Client { Future createPangeaSpace({ required String name, + required String introChatName, + required String announcementsChatName, Visibility visibility = Visibility.private, JoinRules joinRules = JoinRules.public, Uint8List? avatar, @@ -24,6 +26,7 @@ extension SpacesClientExtension on Client { powerLevelContentOverride: {'events_default': 100}, initialState: [ ..._spaceInitialState( + userID!, joinCode, joinRules: joinRules, ), @@ -38,7 +41,11 @@ extension SpacesClientExtension on Client { final space = await _waitForRoom(roomId); if (space == null) return roomId; - await _addDefaultSpaceChats(space: space); + await _addDefaultSpaceChats( + space: space, + introductionsName: introChatName, + announcementsName: announcementsChatName, + ); return roomId; } @@ -108,6 +115,13 @@ extension SpacesClientExtension on Client { throw Exception('Failed to create default space chats'); } + for (final roomId in roomIds) { + final room = getRoomById(roomId); + if (room == null) { + await waitForRoomInSync(roomId, join: true); + } + } + final addIntroChatFuture = space.pangeaSetSpaceChild( roomIds[0], ); @@ -123,6 +137,7 @@ extension SpacesClientExtension on Client { } List _spaceInitialState( + String userID, String joinCode, { required JoinRules joinRules, }) { @@ -130,15 +145,7 @@ extension SpacesClientExtension on Client { StateEvent( type: EventTypes.RoomPowerLevels, stateKey: '', - content: { - 'events': { - EventTypes.SpaceChild: 50, - }, - 'users_default': 0, - 'users': { - userID: SpaceConstants.powerLevelOfAdmin, - }, - }, + content: defaultSpacePowerLevels(userID), ), StateEvent( type: EventTypes.RoomJoinRules, diff --git a/lib/pangea/toolbar/controllers/tts_controller.dart b/lib/pangea/toolbar/controllers/tts_controller.dart index f40c83481..57c42ace4 100644 --- a/lib/pangea/toolbar/controllers/tts_controller.dart +++ b/lib/pangea/toolbar/controllers/tts_controller.dart @@ -24,55 +24,37 @@ import 'package:fluffychat/utils/platform_infos.dart'; import 'package:fluffychat/widgets/matrix.dart'; class TtsController { - final ChatController? chatController; - TtsController({this.chatController}) { + static void initialize() { setAvailableLanguages(); - _languageSubscription = - MatrixState.pangeaController.userController.stateStream.listen( - (_) => setAvailableLanguages(), - ); } - List _availableLangCodes = []; - StreamSubscription? _languageSubscription; + static List _availableLangCodes = []; - final flutter_tts.FlutterTts _tts = flutter_tts.FlutterTts(); - final TextToSpeech _alternativeTTS = TextToSpeech(); - final StreamController loadingChoreoStream = + static final flutter_tts.FlutterTts _tts = flutter_tts.FlutterTts(); + static final TextToSpeech _alternativeTTS = TextToSpeech(); + static final StreamController loadingChoreoStream = StreamController.broadcast(); - bool get _useAlternativeTTS { + static bool get _useAlternativeTTS { return PlatformInfos.isWindows; } - Future dispose() async { - await _tts.stop(); - await _languageSubscription?.cancel(); - await loadingChoreoStream.close(); - } - - void _onError(dynamic message) { - // the package treats this as an error, but it's not - // don't send to sentry - if (message == 'canceled' || message == 'interrupted') { - return; + static Future _onError(dynamic message) async { + if (message != 'canceled' && message != 'interrupted') { + ErrorHandler.logError( + e: 'TTS error', + data: { + 'message': message, + }, + ); } - - ErrorHandler.logError( - e: 'TTS error', - data: { - 'message': message, - }, - ); } - Future setAvailableLanguages() async { + static Future setAvailableLanguages() async { try { if (_useAlternativeTTS) { await _setAvailableAltLanguages(); } else { - _tts.setErrorHandler(_onError); - await _tts.awaitSpeakCompletion(true); await _setAvailableBaseLanguages(); } @@ -86,7 +68,7 @@ class TtsController { } } - Future _setAvailableBaseLanguages() async { + static Future _setAvailableBaseLanguages() async { final voices = (await _tts.getVoices) as List?; _availableLangCodes = (voices ?? []) .map((v) { @@ -100,12 +82,12 @@ class TtsController { .toList(); } - Future _setAvailableAltLanguages() async { + static Future _setAvailableAltLanguages() async { final languages = await _alternativeTTS.getLanguages(); _availableLangCodes = languages.toSet().toList(); } - Future _setSpeakingLanguage(String langCode) async { + static Future _setSpeakingLanguage(String langCode) async { String? selectedLangCode; final langCodeShort = langCode.split("-").first; if (_availableLangCodes.contains(langCode)) { @@ -132,7 +114,7 @@ class TtsController { } } - Future stop() async { + static Future stop() async { try { // return type is dynamic but apparent its supposed to be 1 // https://pub.dev/packages/flutter_tts @@ -157,26 +139,67 @@ class TtsController { } } - /// A safer version of speak, that handles the case of - /// the language not being supported by the TTS engine - Future tryToSpeak( + static VoidCallback? _onStop; + + static Future tryToSpeak( String text, { required String langCode, // Target ID for where to show warning popup String? targetID, BuildContext? context, + ChatController? chatController, + VoidCallback? onStart, + VoidCallback? onStop, + }) async { + final prevOnStop = _onStop; + _onStop = onStop; + + _tts.setErrorHandler((message) { + _onError(message); + prevOnStop?.call(); + }); + + onStart?.call(); + + await _tryToSpeak( + text, + langCode: langCode, + targetID: targetID, + context: context, + chatController: chatController, + onStart: onStart, + onStop: onStop, + ); + + onStop?.call(); + } + + /// A safer version of speak, that handles the case of + /// the language not being supported by the TTS engine + static Future _tryToSpeak( + String text, { + required String langCode, + // Target ID for where to show warning popup + String? targetID, + BuildContext? context, + ChatController? chatController, + VoidCallback? onStart, + VoidCallback? onStop, }) async { chatController?.stopMediaStream.add(null); await _setSpeakingLanguage(langCode); final enableTTS = MatrixState .pangeaController.userController.profile.toolSettings.enableTTS; + if (enableTTS) { final token = PangeaTokenText( offset: 0, content: text, length: text.length, ); + + onStart?.call(); await (_isLangFullySupported(langCode) ? _speak( text, @@ -191,31 +214,33 @@ class TtsController { } else if (targetID != null && context != null) { await _showTTSDisabledPopup(context, targetID); } + + onStop?.call(); } - Future _speak( + static Future _speak( String text, String langCode, List tokens, ) async { try { - stop(); + await stop(); text = text.toLowerCase(); Logs().i('Speaking: $text, langCode: $langCode'); final result = await Future( () => (_useAlternativeTTS - ? _alternativeTTS.speak(text) - : _tts.speak(text)) - .timeout( - const Duration(seconds: 5), - onTimeout: () { - ErrorHandler.logError( - e: "Timeout on tts.speak", - data: {"text": text}, - ); - }, - ), + ? _alternativeTTS.speak(text) + : _tts.speak(text)), + // .timeout( + // const Duration(seconds: 5), + // // onTimeout: () { + // // ErrorHandler.logError( + // // e: "Timeout on tts.speak", + // // data: {"text": text}, + // // ); + // // }, + // ), ); Logs().i('Finished speaking: $text, result: $result'); @@ -241,10 +266,12 @@ class TtsController { }, ); await _speakFromChoreo(text, langCode, tokens); + } finally { + stop(); } } - Future _speakFromChoreo( + static Future _speakFromChoreo( String text, String langCode, List tokens, @@ -252,7 +279,7 @@ class TtsController { TextToSpeechResponse? ttsRes; try { loadingChoreoStream.add(true); - ttsRes = await chatController?.pangeaController.textToSpeech.get( + ttsRes = await MatrixState.pangeaController.textToSpeech.get( TextToSpeechRequest( text: text, langCode: langCode, @@ -304,7 +331,7 @@ class TtsController { } } - bool _isLangFullySupported(String langCode) { + static bool _isLangFullySupported(String langCode) { if (_availableLangCodes.contains(langCode)) { return true; } @@ -317,7 +344,7 @@ class TtsController { return _availableLangCodes.any((lang) => lang.startsWith(langCodeShort)); } - Future _showTTSDisabledPopup( + static Future _showTTSDisabledPopup( BuildContext context, String targetID, ) async => diff --git a/lib/pangea/toolbar/reading_assistance_input_row/practice_match_item.dart b/lib/pangea/toolbar/reading_assistance_input_row/practice_match_item.dart index 795e25857..dd06e779a 100644 --- a/lib/pangea/toolbar/reading_assistance_input_row/practice_match_item.dart +++ b/lib/pangea/toolbar/reading_assistance_input_row/practice_match_item.dart @@ -39,9 +39,6 @@ class PracticeMatchItemState extends State { bool _isHovered = false; bool _isPlaying = false; - TtsController get tts => - widget.overlayController.widget.chatController.choreographer.tts; - bool get isSelected => widget.isSelected; bool? get isCorrect => widget.isCorrect; @@ -52,7 +49,7 @@ class PracticeMatchItemState extends State { } if (_isPlaying) { - await tts.stop(); + await TtsController.stop(); if (mounted) { setState(() => _isPlaying = false); } @@ -64,7 +61,7 @@ class PracticeMatchItemState extends State { final l2 = MatrixState.pangeaController.languageController.activeL2Code(); if (l2 != null) { - await tts.tryToSpeak( + await TtsController.tryToSpeak( widget.audioContent!, context: context, targetID: 'word-audio-button', diff --git a/lib/pangea/toolbar/widgets/message_selection_overlay.dart b/lib/pangea/toolbar/widgets/message_selection_overlay.dart index c5327dac3..844eafc26 100644 --- a/lib/pangea/toolbar/widgets/message_selection_overlay.dart +++ b/lib/pangea/toolbar/widgets/message_selection_overlay.dart @@ -27,6 +27,7 @@ import 'package:fluffychat/pangea/practice_activities/practice_choice.dart'; import 'package:fluffychat/pangea/practice_activities/practice_selection.dart'; import 'package:fluffychat/pangea/practice_activities/practice_selection_repo.dart'; import 'package:fluffychat/pangea/toolbar/controllers/text_to_speech_controller.dart'; +import 'package:fluffychat/pangea/toolbar/controllers/tts_controller.dart'; import 'package:fluffychat/pangea/toolbar/enums/message_mode_enum.dart'; import 'package:fluffychat/pangea/toolbar/enums/reading_assistance_mode_enum.dart'; import 'package:fluffychat/pangea/toolbar/reading_assistance_input_row/morph_selection.dart'; @@ -546,7 +547,7 @@ class MessageOverlayController extends State ) == false || !hideWordCardContent) { - widget.chatController.choreographer.tts.tryToSpeak( + TtsController.tryToSpeak( token.text.content, targetID: null, langCode: pangeaMessageEvent!.messageDisplayLangCode, diff --git a/lib/pangea/toolbar/widgets/overlay_header.dart b/lib/pangea/toolbar/widgets/overlay_header.dart index 9db1fa248..353c5dfda 100644 --- a/lib/pangea/toolbar/widgets/overlay_header.dart +++ b/lib/pangea/toolbar/widgets/overlay_header.dart @@ -9,7 +9,7 @@ import 'package:fluffychat/pages/chat/chat.dart'; import 'package:fluffychat/pangea/events/extensions/pangea_event_extension.dart'; import 'package:fluffychat/pangea/events/utils/report_message.dart'; -class OverlayHeader extends StatelessWidget { +class OverlayHeader extends StatefulWidget { final ChatController controller; const OverlayHeader({ @@ -17,6 +17,21 @@ class OverlayHeader extends StatelessWidget { super.key, }); + @override + State createState() => OverlayHeaderState(); +} + +class OverlayHeaderState extends State { + ChatController get controller => widget.controller; + + final ScrollController _scrollController = ScrollController(); + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { final l10n = L10n.of(context); @@ -35,84 +50,106 @@ class OverlayHeader extends StatelessWidget { child: Row( mainAxisAlignment: MainAxisAlignment.end, children: [ - if (controller.selectedEvents.length == 1) - IconButton( - icon: const Icon(Symbols.reply_all), - tooltip: l10n.reply, - onPressed: controller.replyAction, - color: theme.colorScheme.primary, - ), - IconButton( - icon: const Icon(Symbols.forward), - tooltip: l10n.forward, - onPressed: controller.forwardEventsAction, - color: theme.colorScheme.primary, - ), - if (controller.selectedEvents.length == 1 && - controller.selectedEvents.single.messageType == MessageTypes.Text) - IconButton( - icon: const Icon(Icons.copy_outlined), - tooltip: l10n.copy, - onPressed: controller.copyEventsAction, - color: theme.colorScheme.primary, - ), - if (controller.canSaveSelectedEvent) - // Use builder context to correctly position the share dialog on iPad - Builder( - builder: (context) => IconButton( - icon: const Icon(Symbols.download), - tooltip: L10n.of(context).download, - onPressed: () => controller.saveSelectedEvent(context), - color: theme.colorScheme.primary, + Expanded( + child: Scrollbar( + thumbVisibility: true, + controller: _scrollController, + child: Align( + alignment: Alignment.centerRight, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + controller: _scrollController, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Row( + children: [ + if (controller.selectedEvents.length == 1) + IconButton( + icon: const Icon(Symbols.reply_all), + tooltip: l10n.reply, + onPressed: controller.replyAction, + color: theme.colorScheme.primary, + ), + IconButton( + icon: const Icon(Symbols.forward), + tooltip: l10n.forward, + onPressed: controller.forwardEventsAction, + color: theme.colorScheme.primary, + ), + if (controller.selectedEvents.length == 1 && + controller.selectedEvents.single.messageType == + MessageTypes.Text) + IconButton( + icon: const Icon(Icons.copy_outlined), + tooltip: l10n.copy, + onPressed: controller.copyEventsAction, + color: theme.colorScheme.primary, + ), + if (controller.canSaveSelectedEvent) + // Use builder context to correctly position the share dialog on iPad + Builder( + builder: (context) => IconButton( + icon: const Icon(Symbols.download), + tooltip: L10n.of(context).download, + onPressed: () => + controller.saveSelectedEvent(context), + color: theme.colorScheme.primary, + ), + ), + if (controller.canPinSelectedEvents) + IconButton( + icon: const Icon(Icons.push_pin_outlined), + onPressed: controller.pinEvent, + tooltip: l10n.pinMessage, + color: theme.colorScheme.primary, + ), + if (controller.canEditSelectedEvents && + !controller.selectedEvents.first.isActivityMessage) + IconButton( + icon: const Icon(Icons.edit_outlined), + tooltip: l10n.edit, + onPressed: controller.editSelectedEventAction, + color: theme.colorScheme.primary, + ), + if (controller.canRedactSelectedEvents) + IconButton( + icon: const Icon(Icons.delete_outlined), + tooltip: l10n.redactMessage, + onPressed: controller.redactEventsAction, + color: theme.colorScheme.primary, + ), + if (controller.selectedEvents.length == 1) + IconButton( + icon: const Icon(Icons.shield_outlined), + tooltip: l10n.reportMessage, + onPressed: () { + final event = controller.selectedEvents.first; + controller.clearSelectedEvents(); + reportEvent( + event, + controller, + controller.context, + ); + }, + color: theme.colorScheme.primary, + ), + if (controller.selectedEvents.length == 1) + IconButton( + icon: const Icon(Icons.info_outlined), + tooltip: l10n.messageInfo, + color: theme.colorScheme.primary, + onPressed: () { + controller.showEventInfo(); + controller.clearSelectedEvents(); + }, + ), + ], + ), + ), + ), ), ), - if (controller.canPinSelectedEvents) - IconButton( - icon: const Icon(Icons.push_pin_outlined), - onPressed: controller.pinEvent, - tooltip: l10n.pinMessage, - color: theme.colorScheme.primary, - ), - if (controller.canEditSelectedEvents && - !controller.selectedEvents.first.isActivityMessage) - IconButton( - icon: const Icon(Icons.edit_outlined), - tooltip: l10n.edit, - onPressed: controller.editSelectedEventAction, - color: theme.colorScheme.primary, - ), - if (controller.canRedactSelectedEvents) - IconButton( - icon: const Icon(Icons.delete_outlined), - tooltip: l10n.redactMessage, - onPressed: controller.redactEventsAction, - color: theme.colorScheme.primary, - ), - if (controller.selectedEvents.length == 1) - IconButton( - icon: const Icon(Icons.shield_outlined), - tooltip: l10n.reportMessage, - onPressed: () { - final event = controller.selectedEvents.first; - controller.clearSelectedEvents(); - reportEvent( - event, - controller, - controller.context, - ); - }, - color: theme.colorScheme.primary, - ), - if (controller.selectedEvents.length == 1) - IconButton( - icon: const Icon(Icons.info_outlined), - tooltip: l10n.messageInfo, - color: theme.colorScheme.primary, - onPressed: () { - controller.showEventInfo(); - controller.clearSelectedEvents(); - }, - ), + ), ], ), ); diff --git a/lib/pangea/toolbar/widgets/overlay_message.dart b/lib/pangea/toolbar/widgets/overlay_message.dart index f492ccb73..21bf0cc07 100644 --- a/lib/pangea/toolbar/widgets/overlay_message.dart +++ b/lib/pangea/toolbar/widgets/overlay_message.dart @@ -80,9 +80,11 @@ class OverlayMessage extends StatelessWidget { previousEvent!.senderId == event.senderId && previousEvent!.originServerTs.sameEnvironment(event.originServerTs); - final textColor = ownMessage - ? ThemeData.dark().colorScheme.onPrimary - : theme.colorScheme.onSurface; + final textColor = event.isActivityMessage + ? ThemeData.light().colorScheme.onPrimary + : ownMessage + ? ThemeData.dark().colorScheme.onPrimary + : theme.colorScheme.onSurface; final linkColor = theme.brightness == Brightness.light ? theme.colorScheme.primary diff --git a/lib/pangea/toolbar/widgets/practice_activity/multiple_choice_activity.dart b/lib/pangea/toolbar/widgets/practice_activity/multiple_choice_activity.dart index 47a36c938..3bf05cebf 100644 --- a/lib/pangea/toolbar/widgets/practice_activity/multiple_choice_activity.dart +++ b/lib/pangea/toolbar/widgets/practice_activity/multiple_choice_activity.dart @@ -13,7 +13,6 @@ import 'package:fluffychat/pangea/morphs/get_grammar_copy.dart'; import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart'; import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart'; import 'package:fluffychat/pangea/practice_activities/practice_record.dart'; -import 'package:fluffychat/pangea/toolbar/controllers/tts_controller.dart'; import 'package:fluffychat/pangea/toolbar/widgets/message_audio_card.dart'; import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart'; import 'package:fluffychat/pangea/toolbar/widgets/practice_activity/practice_activity_card.dart'; @@ -80,9 +79,6 @@ class MultipleChoiceActivityState extends State { } } - TtsController get tts => - widget.overlayController.widget.chatController.choreographer.tts; - void updateChoice(String value, int index) { final bool isCorrect = widget.currentActivity.multipleChoiceContent!.isCorrect(value, index); @@ -232,7 +228,7 @@ class MultipleChoiceActivityState extends State { text: practiceActivity.multipleChoiceContent!.answers.first, uniqueID: "audio-activity-${widget.event.eventId}", langCode: widget - .overlayController.pangeaMessageEvent?.messageDisplayLangCode, + .overlayController.pangeaMessageEvent!.messageDisplayLangCode, ), if (practiceActivity.activityType == ActivityTypeEnum.hiddenWordListening) @@ -251,8 +247,8 @@ class MultipleChoiceActivityState extends State { choices: choices(context), isActive: true, id: currentRecordModel?.hashCode.toString(), - tts: practiceActivity.activityType.includeTTSOnClick ? tts : null, - enableAudio: !widget.overlayController.isPlayingAudio, + enableAudio: !widget.overlayController.isPlayingAudio && + practiceActivity.activityType.includeTTSOnClick, langCode: MatrixState.pangeaController.languageController.activeL2Code(), getDisplayCopy: _getDisplayCopy, diff --git a/lib/pangea/toolbar/widgets/practice_activity/practice_activity_card.dart b/lib/pangea/toolbar/widgets/practice_activity/practice_activity_card.dart index cc7bf514b..fbc456635 100644 --- a/lib/pangea/toolbar/widgets/practice_activity/practice_activity_card.dart +++ b/lib/pangea/toolbar/widgets/practice_activity/practice_activity_card.dart @@ -15,6 +15,7 @@ import 'package:fluffychat/pangea/practice_activities/practice_activity_model.da import 'package:fluffychat/pangea/practice_activities/practice_generation_repo.dart'; import 'package:fluffychat/pangea/practice_activities/practice_record.dart'; import 'package:fluffychat/pangea/practice_activities/practice_target.dart'; +import 'package:fluffychat/pangea/toolbar/controllers/tts_controller.dart'; import 'package:fluffychat/pangea/toolbar/event_wrappers/practice_activity_event.dart'; import 'package:fluffychat/pangea/toolbar/reading_assistance_input_row/message_morph_choice.dart'; import 'package:fluffychat/pangea/toolbar/reading_assistance_input_row/practice_match_card.dart'; @@ -231,7 +232,7 @@ class PracticeActivityCardState extends State { widget.overlayController .onActivityFinish(currentActivity!.activityType, null); - widget.overlayController.widget.chatController.choreographer.tts.stop(); + TtsController.stop(); } catch (e, s) { _onError(); debugger(when: kDebugMode); diff --git a/lib/pangea/toolbar/widgets/practice_activity/word_audio_button.dart b/lib/pangea/toolbar/widgets/practice_activity/word_audio_button.dart index 583a1b3d0..37ce838d8 100644 --- a/lib/pangea/toolbar/widgets/practice_activity/word_audio_button.dart +++ b/lib/pangea/toolbar/widgets/practice_activity/word_audio_button.dart @@ -1,8 +1,9 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; -import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/toolbar/controllers/tts_controller.dart'; import 'package:fluffychat/widgets/matrix.dart'; @@ -11,7 +12,7 @@ class WordAudioButton extends StatefulWidget { final bool isSelected; final double baseOpacity; final String uniqueID; - final String? langCode; + final String langCode; final EdgeInsets? padding; /// If defined, this callback will be called instead of the default one @@ -21,10 +22,10 @@ class WordAudioButton extends StatefulWidget { super.key, required this.text, required this.uniqueID, + required this.langCode, this.isSelected = false, this.baseOpacity = 1, this.callbackOverride, - this.langCode, this.padding, }); @@ -33,8 +34,19 @@ class WordAudioButton extends StatefulWidget { } class WordAudioButtonState extends State { - final TtsController tts = TtsController(); + late TtsController tts; bool _isPlaying = false; + bool _isLoading = false; + StreamSubscription? _loadingChoreoSubscription; + + @override + void initState() { + super.initState(); + _loadingChoreoSubscription = + TtsController.loadingChoreoStream.stream.listen((val) { + if (mounted) setState(() => _isLoading = val); + }); + } @override void didUpdateWidget(covariant WordAudioButton oldWidget) { @@ -47,7 +59,8 @@ class WordAudioButtonState extends State { @override void dispose() { - tts.dispose(); + TtsController.stop(); + _loadingChoreoSubscription?.cancel(); super.dispose(); } @@ -71,45 +84,34 @@ class WordAudioButtonState extends State { onTap: widget.callbackOverride ?? () async { if (_isPlaying) { - await tts.stop(); - if (mounted) { - setState(() => _isPlaying = false); - } + await TtsController.stop(); } else { - if (mounted) { - setState(() => _isPlaying = true); - } - try { - if (widget.langCode != null) { - await tts.tryToSpeak( - widget.text, - context: context, - targetID: 'word-audio-button-${widget.uniqueID}', - langCode: widget.langCode!, - ); - } - } catch (e, s) { - ErrorHandler.logError( - e: e, - s: s, - data: { - "text": widget.text, - }, - ); - } finally { - if (mounted) { - setState(() => _isPlaying = false); - } - } + await TtsController.tryToSpeak( + widget.text, + context: context, + targetID: 'word-audio-button-${widget.uniqueID}', + langCode: widget.langCode, + onStart: () => setState(() => _isPlaying = true), + onStop: () => setState(() => _isPlaying = false), + ); } }, child: Padding( padding: widget.padding ?? const EdgeInsets.all(0.0), - child: Icon( - _isPlaying ? Icons.pause_outlined : Icons.volume_up, - color: - _isPlaying ? Theme.of(context).colorScheme.primary : null, - ), + child: _isLoading + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 3, + ), + ) + : Icon( + _isPlaying ? Icons.pause_outlined : Icons.volume_up, + color: _isPlaying + ? Theme.of(context).colorScheme.primary + : null, + ), ), ), ), diff --git a/lib/pangea/toolbar/widgets/practice_activity/word_text_with_audio_button.dart b/lib/pangea/toolbar/widgets/practice_activity/word_text_with_audio_button.dart index 6a4f586ad..9297d3b3a 100644 --- a/lib/pangea/toolbar/widgets/practice_activity/word_text_with_audio_button.dart +++ b/lib/pangea/toolbar/widgets/practice_activity/word_text_with_audio_button.dart @@ -1,139 +1,46 @@ -import 'dart:async'; - import 'package:flutter/material.dart'; -import 'package:fluffychat/pangea/common/utils/error_handler.dart'; -import 'package:fluffychat/pangea/toolbar/controllers/tts_controller.dart'; -import 'package:fluffychat/widgets/matrix.dart'; +import 'package:fluffychat/pangea/toolbar/widgets/practice_activity/word_audio_button.dart'; -class WordTextWithAudioButton extends StatefulWidget { +class WordTextWithAudioButton extends StatelessWidget { final String text; final String uniqueID; final TextStyle? style; final double? iconSize; + final String langCode; const WordTextWithAudioButton({ super.key, required this.text, required this.uniqueID, + required this.langCode, this.style, this.iconSize, }); - @override - WordAudioButtonState createState() => WordAudioButtonState(); -} - -class WordAudioButtonState extends State { - // initialize as null because we don't know if we need to load - // audio from choreo yet. This shall remain null if user device support - // text to speech - final bool? _isLoadingAudio = null; - final TtsController tts = TtsController(); - - bool _isPlaying = false; - bool _isLoading = false; - StreamSubscription? _loadingChoreoSubscription; - - @override - void initState() { - super.initState(); - _loadingChoreoSubscription = tts.loadingChoreoStream.stream.listen((val) { - if (mounted) setState(() => _isLoading = val); - }); - } - - @override - void dispose() { - _loadingChoreoSubscription?.cancel(); - tts.dispose(); - super.dispose(); - } - @override Widget build(BuildContext context) { - return CompositedTransformTarget( - link: MatrixState.pAnyState - .layerLinkAndKey('text-audio-button-${widget.uniqueID}') - .link, - child: MouseRegion( - key: MatrixState.pAnyState - .layerLinkAndKey('text-audio-button-${widget.uniqueID}') - .key, - cursor: SystemMouseCursors.click, - onEnter: (event) => setState(() {}), - onExit: (event) => setState(() {}), - child: GestureDetector( - onTap: () async { - if (_isLoadingAudio == true) { - return; - } - if (_isPlaying) { - await tts.stop(); - if (mounted) { - setState(() => _isPlaying = false); - } - } else { - if (mounted) { - setState(() => _isPlaying = true); - } - try { - final l2 = MatrixState.pangeaController.languageController - .activeL2Code(); - if (l2 != null) { - await tts.tryToSpeak( - widget.text, - context: context, - targetID: 'text-audio-button-${widget.uniqueID}', - langCode: l2, - ); - } - } catch (e, s) { - ErrorHandler.logError( - e: e, - s: s, - data: { - "text": widget.text, - }, - ); - } finally { - if (mounted) { - setState(() => _isPlaying = false); - } - } - } - }, - child: Row( - mainAxisSize: MainAxisSize.min, - spacing: 8.0, - children: [ - ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 180), - child: Text( - widget.text, - style: widget.style ?? Theme.of(context).textTheme.bodyMedium, - overflow: TextOverflow.ellipsis, - ), - ), - if (_isLoading) - const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 3, - ), - ) - else - Icon( - _isPlaying ? Icons.pause_outlined : Icons.volume_up, - color: - _isPlaying ? Theme.of(context).colorScheme.primary : null, - size: widget.iconSize, - ), - ], + return Row( + mainAxisSize: MainAxisSize.min, + spacing: 8.0, + children: [ + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 180), + child: Text( + text, + style: style ?? Theme.of(context).textTheme.bodyMedium, + overflow: TextOverflow.ellipsis, ), ), - ), + WordAudioButton( + text: text, + uniqueID: uniqueID, + isSelected: false, + baseOpacity: 1, + langCode: langCode, + padding: const EdgeInsets.only(left: 8.0), + ), + ], ); } } diff --git a/lib/pangea/toolbar/widgets/reading_assistance_content.dart b/lib/pangea/toolbar/widgets/reading_assistance_content.dart index facdb86e5..7886915fb 100644 --- a/lib/pangea/toolbar/widgets/reading_assistance_content.dart +++ b/lib/pangea/toolbar/widgets/reading_assistance_content.dart @@ -9,7 +9,6 @@ import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart'; import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart'; -import 'package:fluffychat/pangea/toolbar/controllers/tts_controller.dart'; import 'package:fluffychat/pangea/toolbar/enums/message_mode_enum.dart'; import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart'; import 'package:fluffychat/pangea/toolbar/widgets/message_unsubscribed_card.dart'; @@ -38,9 +37,6 @@ class ReadingAssistanceContent extends StatefulWidget { } class ReadingAssistanceContentState extends State { - TtsController get ttsController => - widget.overlayController.widget.chatController.choreographer.tts; - Widget? toolbarContent(BuildContext context) { final bool? subscribed = MatrixState.pangeaController.subscriptionController.isSubscribed; @@ -123,7 +119,6 @@ class ReadingAssistanceContentState extends State { return WordZoomWidget( token: widget.overlayController.selectedToken!, messageEvent: widget.overlayController.pangeaMessageEvent!, - tts: ttsController, overlayController: widget.overlayController, ); } diff --git a/lib/pangea/toolbar/widgets/word_zoom/lemma_widget.dart b/lib/pangea/toolbar/widgets/word_zoom/lemma_widget.dart index 7a0fbe1d5..d8786f438 100644 --- a/lib/pangea/toolbar/widgets/word_zoom/lemma_widget.dart +++ b/lib/pangea/toolbar/widgets/word_zoom/lemma_widget.dart @@ -8,7 +8,6 @@ import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dar import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; import 'package:fluffychat/pangea/events/models/tokens_event_content_model.dart'; import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; -import 'package:fluffychat/pangea/toolbar/controllers/tts_controller.dart'; import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart'; import 'package:fluffychat/pangea/toolbar/widgets/practice_activity/word_audio_button.dart'; import 'package:fluffychat/widgets/future_loading_dialog.dart'; @@ -18,7 +17,6 @@ class LemmaWidget extends StatefulWidget { final PangeaMessageEvent pangeaMessageEvent; final VoidCallback onEdit; final VoidCallback onEditDone; - final TtsController tts; final MessageOverlayController? overlayController; const LemmaWidget({ @@ -27,7 +25,6 @@ class LemmaWidget extends StatefulWidget { required this.pangeaMessageEvent, required this.onEdit, required this.onEditDone, - required this.tts, required this.overlayController, }); diff --git a/lib/pangea/toolbar/widgets/word_zoom/word_zoom_widget.dart b/lib/pangea/toolbar/widgets/word_zoom/word_zoom_widget.dart index ae0d41789..37f1dd6cc 100644 --- a/lib/pangea/toolbar/widgets/word_zoom/word_zoom_widget.dart +++ b/lib/pangea/toolbar/widgets/word_zoom/word_zoom_widget.dart @@ -10,7 +10,6 @@ import 'package:fluffychat/pangea/lemmas/construct_xp_widget.dart'; import 'package:fluffychat/pangea/lemmas/lemma_emoji_row.dart'; import 'package:fluffychat/pangea/morphs/morph_features_enum.dart'; import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart'; -import 'package:fluffychat/pangea/toolbar/controllers/tts_controller.dart'; import 'package:fluffychat/pangea/toolbar/enums/message_mode_enum.dart'; import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart'; import 'package:fluffychat/pangea/toolbar/widgets/practice_activity/word_audio_button.dart'; @@ -22,14 +21,12 @@ import 'package:fluffychat/widgets/matrix.dart'; class WordZoomWidget extends StatelessWidget { final PangeaToken token; final PangeaMessageEvent messageEvent; - final TtsController tts; final MessageOverlayController overlayController; const WordZoomWidget({ super.key, required this.token, required this.messageEvent, - required this.tts, required this.overlayController, }); @@ -93,7 +90,6 @@ class WordZoomWidget extends StatelessWidget { debugPrint("what are we doing edits with?"); _onEditDone(); }, - tts: tts, overlayController: overlayController, ), ConstructXpWidget( @@ -181,7 +177,7 @@ class WordZoomWidget extends StatelessWidget { baseOpacity: 0.4, uniqueID: "word-zoom-audio-${_selectedToken.text.content}", langCode: overlayController - .pangeaMessageEvent?.messageDisplayLangCode, + .pangeaMessageEvent!.messageDisplayLangCode, ), ], ..._selectedToken.morphsBasicallyEligibleForPracticeByPriority diff --git a/lib/utils/url_launcher.dart b/lib/utils/url_launcher.dart index 615b6d505..5dff82be7 100644 --- a/lib/utils/url_launcher.dart +++ b/lib/utils/url_launcher.dart @@ -8,11 +8,11 @@ import 'package:punycode/punycode.dart'; import 'package:url_launcher/url_launcher_string.dart'; import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/pangea/public_spaces/public_room_bottom_sheet.dart'; import 'package:fluffychat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart'; import 'package:fluffychat/widgets/adaptive_dialogs/user_dialog.dart'; import 'package:fluffychat/widgets/future_loading_dialog.dart'; import 'package:fluffychat/widgets/matrix.dart'; -import '../widgets/adaptive_dialogs/public_room_dialog.dart'; import 'platform_infos.dart'; class UrlLauncher { @@ -188,9 +188,10 @@ class UrlLauncher { // roomAlias: identityParts.primaryIdentifier, // ), // ); - await PublicRoomDialog.show( + await PublicRoomBottomSheet.show( context: context, roomAlias: identityParts.primaryIdentifier, + // Pangea# ); // Pangea# } diff --git a/lib/widgets/adaptive_dialogs/public_room_dialog.dart b/lib/widgets/adaptive_dialogs/public_room_dialog.dart index 5d1fc64b0..4f0070ffd 100644 --- a/lib/widgets/adaptive_dialogs/public_room_dialog.dart +++ b/lib/widgets/adaptive_dialogs/public_room_dialog.dart @@ -6,7 +6,6 @@ import 'package:flutter_linkify/flutter_linkify.dart'; import 'package:go_router/go_router.dart'; import 'package:matrix/matrix.dart'; -import 'package:fluffychat/pangea/common/config/environment.dart'; import 'package:fluffychat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart'; import '../../config/themes.dart'; import '../../utils/url_launcher.dart'; @@ -17,58 +16,12 @@ import '../matrix.dart'; import '../mxc_image_viewer.dart'; import 'adaptive_dialog_action.dart'; -// #Pangea -// class PublicRoomDialog extends StatelessWidget { -class PublicRoomDialog extends StatefulWidget { - // Pangea# +class PublicRoomDialog extends StatelessWidget { final String? roomAlias; final PublicRoomsChunk? chunk; final List? via; const PublicRoomDialog({super.key, this.roomAlias, this.chunk, this.via}); - // #Pangea - static Future show({ - required BuildContext context, - String? roomAlias, - PublicRoomsChunk? chunk, - List? via, - }) async { - final room = MatrixState.pangeaController.matrixState.client - .getRoomById(chunk!.roomId); - - if (room != null && room.membership == Membership.join) { - context.go("/rooms?spaceId=${room.id}"); - return null; - } - - return showAdaptiveDialog( - context: context, - barrierDismissible: true, - builder: (context) => PublicRoomDialog( - roomAlias: roomAlias, - chunk: chunk, - via: via, - ), - ); - } - - @override - State createState() => PublicRoomDialogState(); -} - -class PublicRoomDialogState extends State { - PublicRoomsChunk? get chunk => widget.chunk; - String? get roomAlias => widget.roomAlias; - List? get via => widget.via; - - final TextEditingController _codeController = TextEditingController(); - - @override - void dispose() { - _codeController.dispose(); - super.dispose(); - } - // Pangea# void _joinRoom(BuildContext context) async { final client = Matrix.of(context).client; @@ -87,16 +40,9 @@ class PublicRoomDialogState extends State { via: via, ); - // #Pangea - // if (!knock && client.getRoomById(roomId) == null) { - // await client.waitForRoomInSync(roomId); - // } - final room = client.getRoomById(roomId); - if (!knock && (room == null || room.membership != Membership.join)) { - await client.waitForRoomInSync(roomId, join: true); + if (!knock && client.getRoomById(roomId) == null) { + await client.waitForRoomInSync(roomId); } - // Pangea# - return roomId; }, ); @@ -119,11 +65,6 @@ class PublicRoomDialogState extends State { !client.getRoomById(result.result!)!.isSpace) { context.go('/rooms/$roomId'); } - // #Pangea - else { - context.go('/rooms?spaceId=$roomId'); - } - // Pangea# return; } @@ -144,31 +85,10 @@ class PublicRoomDialogState extends State { return query.chunk.firstWhere(_testRoom); } - // #Pangea - Future _joinWithCode() async { - final resp = - await MatrixState.pangeaController.classController.joinClasswithCode( - context, - _codeController.text, - notFoundError: L10n.of(context).notTheCodeError, - ); - if (!resp.isError) { - Navigator.of(context).pop(true); - } - } - // Pangea# - @override Widget build(BuildContext context) { final roomAlias = this.roomAlias ?? chunk?.canonicalAlias; - // #Pangea - // final roomLink = roomAlias ?? chunk?.roomId; - String? roomLink = roomAlias ?? chunk?.roomId; - if (roomLink != null) { - roomLink = - "${Environment.frontendURL}/#/join_with_alias?alias=${Uri.encodeComponent(roomLink)}"; - } - // Pangea# + final roomLink = roomAlias ?? chunk?.roomId; var copied = false; return AlertDialog.adaptive( title: ConstrainedBox( @@ -179,13 +99,7 @@ class PublicRoomDialogState extends State { ), ), content: ConstrainedBox( - // #Pangea - // constraints: const BoxConstraints(maxWidth: 256, maxHeight: 256), - constraints: const BoxConstraints( - maxWidth: 256, - maxHeight: 300, - ), - // Pangea# + constraints: const BoxConstraints(maxWidth: 256, maxHeight: 256), child: FutureBuilder( future: _search(context), builder: (context, snapshot) { @@ -208,10 +122,7 @@ class PublicRoomDialogState extends State { child: GestureDetector( onTap: () { Clipboard.setData( - // #Pangea - // ClipboardData(text: roomLink), - ClipboardData(text: roomLink!), - // Pangea# + ClipboardData(text: roomLink), ); setState(() { copied = true; @@ -243,12 +154,7 @@ class PublicRoomDialogState extends State { ), ), ), - // #Pangea - // TextSpan(text: roomLink), - TextSpan( - text: L10n.of(context).shareSpaceLink, - ), - // Pangea# + TextSpan(text: roomLink), ], style: theme.textTheme.bodyMedium ?.copyWith(fontSize: 10), @@ -280,79 +186,6 @@ class PublicRoomDialogState extends State { style: const TextStyle(fontSize: 10), textAlign: TextAlign.center, ), - // #Pangea - Material( - type: MaterialType.transparency, - child: Container( - decoration: BoxDecoration( - border: Border.all( - color: Theme.of(context).colorScheme.outline, - ), - borderRadius: BorderRadius.circular(24), - ), - child: Row( - children: [ - Expanded( - child: TextField( - style: const TextStyle( - fontSize: 12, - ), - controller: _codeController, - decoration: InputDecoration( - border: InputBorder.none, - focusedBorder: InputBorder.none, - enabledBorder: InputBorder.none, - errorBorder: InputBorder.none, - disabledBorder: InputBorder.none, - hintText: L10n.of(context).enterSpaceCode, - contentPadding: const EdgeInsets.symmetric( - horizontal: 16.0, - ), - labelStyle: const TextStyle( - fontSize: 12, - ), - hintStyle: TextStyle( - color: Theme.of(context).hintColor, - fontSize: 12, - ), - isDense: true, - ), - ), - ), - Container( - decoration: BoxDecoration( - border: Border( - left: BorderSide( - color: Theme.of(context).colorScheme.outline, - ), - ), - ), - child: ElevatedButton( - onPressed: _joinWithCode, - style: ElevatedButton.styleFrom( - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.only( - topLeft: Radius.zero, - bottomLeft: Radius.zero, - topRight: Radius.circular(24), - bottomRight: Radius.circular(24), - ), - ), - padding: EdgeInsets.zero, - ), - child: Text( - L10n.of(context).join, - style: const TextStyle( - fontSize: 12, - ), - ), - ), - ), - ], - ), - ), - ), - // Pangea# if (topic != null && topic.isNotEmpty) SelectableLinkify( text: topic, @@ -381,10 +214,7 @@ class PublicRoomDialogState extends State { child: Text( chunk?.joinRule == 'knock' && Matrix.of(context).client.getRoomById(chunk!.roomId) == null - // #Pangea - // ? L10n.of(context).knock - ? L10n.of(context).askToJoin - // Pangea# + ? L10n.of(context).knock : chunk?.roomType == 'm.space' ? L10n.of(context).joinSpace : L10n.of(context).joinRoom, diff --git a/lib/widgets/matrix.dart b/lib/widgets/matrix.dart index 3f1673033..1dea15a8d 100644 --- a/lib/widgets/matrix.dart +++ b/lib/widgets/matrix.dart @@ -20,6 +20,7 @@ import 'package:url_launcher/url_launcher_string.dart'; import 'package:fluffychat/pangea/common/controllers/pangea_controller.dart'; import 'package:fluffychat/pangea/common/utils/any_state_holder.dart'; +import 'package:fluffychat/pangea/toolbar/controllers/tts_controller.dart'; import 'package:fluffychat/utils/client_manager.dart'; import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_file_extension.dart'; import 'package:fluffychat/utils/platform_infos.dart'; @@ -249,6 +250,7 @@ class MatrixState extends State with WidgetsBindingObserver { ), ); pangeaController = PangeaController(matrix: widget, matrixState: this); + TtsController.initialize(); // Pangea# } @@ -482,6 +484,12 @@ class MatrixState extends State with WidgetsBindingObserver { AppConfig.showPresences = store.getBool(SettingKeys.showPresences) ?? AppConfig.showPresences; + + // #Pangea + AppConfig.displayNavigationRail = + store.getBool(SettingKeys.displayNavigationRail) ?? + AppConfig.displayNavigationRail; + // Pangea# } @override diff --git a/lib/widgets/navigation_rail.dart b/lib/widgets/navigation_rail.dart index d3def0630..7ae9665b5 100644 --- a/lib/widgets/navigation_rail.dart +++ b/lib/widgets/navigation_rail.dart @@ -34,6 +34,15 @@ class SpacesNavigationRail extends StatelessWidget { .uri .path .startsWith('/rooms/settings'); + // #Pangea + final isHomepage = GoRouter.of(context) + .routeInformationProvider + .value + .uri + .path + .contains('homepage'); + final isColumnMode = FluffyThemes.isColumnMode(context); + // Pangea# return StreamBuilder( key: ValueKey( client.userID.toString(), @@ -53,7 +62,12 @@ class SpacesNavigationRail extends StatelessWidget { .toList(); return SizedBox( - width: FluffyThemes.navRailWidth, + // #Pangea + // width: FluffyThemes.navRailWidth, + width: isColumnMode + ? FluffyThemes.navRailWidth + : FluffyThemes.navRailWidth * 0.75, + // Pangea# child: Column( children: [ Expanded( @@ -61,35 +75,56 @@ class SpacesNavigationRail extends StatelessWidget { scrollDirection: Axis.vertical, // #Pangea // itemCount: rootSpaces.length + 2, - itemCount: rootSpaces.length + 3, + itemCount: rootSpaces.length + 4, // Pangea# itemBuilder: (context, i) { + // #Pangea if (i == 0) { return NaviRailItem( - isSelected: activeSpaceId == null && !isSettings, - onTap: onGoToChats, + isSelected: isColumnMode + ? activeSpaceId == null && !isSettings + : isHomepage, + onTap: () => isColumnMode + ? onGoToChats() + : context.go("/rooms/homepage"), icon: const Padding( padding: EdgeInsets.all(10.0), - // #Pangea - // child: Icon(Icons.forum_outlined), child: Icon(Icons.home_outlined), - // Pangea# ), selectedIcon: const Padding( padding: EdgeInsets.all(10.0), - // #Pangea - // child: Icon(Icons.forum), child: Icon(Icons.home), - // Pangea# ), - // #Pangea - // toolTip: L10n.of(context).chats, toolTip: L10n.of(context).home, - // Pangea# unreadBadgeFilter: (room) => true, ); } i--; + // Pangea# + if (i == 0) { + return isColumnMode + ? const SizedBox() + : NaviRailItem( + // #Pangea + // isSelected: activeSpaceId == null && !isSettings, + isSelected: activeSpaceId == null && + !isSettings && + !isHomepage, + // Pangea# + onTap: onGoToChats, + icon: const Padding( + padding: EdgeInsets.all(10.0), + child: Icon(Icons.forum_outlined), + ), + selectedIcon: const Padding( + padding: EdgeInsets.all(10.0), + child: Icon(Icons.forum), + ), + toolTip: L10n.of(context).chats, + unreadBadgeFilter: (room) => true, + ); + } + i--; if (i == rootSpaces.length) { // #Pangea return NaviRailItem( diff --git a/pubspec.yaml b/pubspec.yaml index 7ca3398e2..b8b738d3d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -6,7 +6,7 @@ description: Learn a language while texting your friends. # Pangea# publish_to: none # On version bump also increase the build number for F-Droid -version: 4.1.10+1 +version: 4.1.10+2 environment: sdk: ">=3.0.0 <4.0.0"