From b9d23cc7e385e6683ad84a2b4a86ad6df42ec865 Mon Sep 17 00:00:00 2001 From: Kelrap <99418823+Kelrap@users.noreply.github.com> Date: Thu, 15 May 2025 13:34:47 -0400 Subject: [PATCH 01/26] Hide redacted events by default (#2814) * Hide redacted events by default * chore: formatting --------- Co-authored-by: ggurdin --- lib/config/app_config.dart | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/config/app_config.dart b/lib/config/app_config.dart index 0fbd529c2..33a97c5cc 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; From 818078339a96bba8eaf3c4fdf4db533288230a48 Mon Sep 17 00:00:00 2001 From: Kelrap <99418823+Kelrap@users.noreply.github.com> Date: Thu, 15 May 2025 13:39:16 -0400 Subject: [PATCH 02/26] Public sheet popup reversion (#2791) * Don't ask for space code of no-code-required space * Fix Join button gap * Reverts to previous public sheet popup design * Make join button work * Copy fixes for subchat join orange box error * revert public room dialog to fluffychat version * fix import error * Keep sheet from taking up whole screen * chore: formatting --------- Co-authored-by: ggurdin <46800240+ggurdin@users.noreply.github.com> Co-authored-by: ggurdin --- lib/pages/chat_list/chat_list_body.dart | 4 +- lib/pages/chat_list/space_view.dart | 4 +- .../public_room_bottom_sheet.dart | 393 ++++++++++++++++++ .../public_spaces/public_space_card.dart | 6 +- lib/utils/url_launcher.dart | 5 +- .../adaptive_dialogs/public_room_dialog.dart | 186 +-------- 6 files changed, 411 insertions(+), 187 deletions(-) create mode 100644 lib/pangea/public_spaces/public_room_bottom_sheet.dart diff --git a/lib/pages/chat_list/chat_list_body.dart b/lib/pages/chat_list/chat_list_body.dart index f6a47adf1..39e08dab3 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'; @@ -384,7 +384,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/space_view.dart b/lib/pages/chat_list/space_view.dart index 4471a01d8..24533cc31 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 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..6d0ca3ca3 --- /dev/null +++ b/lib/pangea/public_spaces/public_room_bottom_sheet.dart @@ -0,0 +1,393 @@ +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, + 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, + 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..a4873e65d 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 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, From c5675d6cb3da5c598f6c2464651660340b9ebc9a Mon Sep 17 00:00:00 2001 From: ggurdin <46800240+ggurdin@users.noreply.github.com> Date: Thu, 15 May 2025 14:03:31 -0400 Subject: [PATCH 03/26] chore: make overlay header scrollable (#2817) --- .../toolbar/widgets/overlay_header.dart | 191 +++++++++++------- 1 file changed, 114 insertions(+), 77 deletions(-) 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(); - }, - ), + ), ], ), ); From 454ddeb2c08a4bb6329e9aeba7ea2d6f7015e390 Mon Sep 17 00:00:00 2001 From: Kelrap <99418823+Kelrap@users.noreply.github.com> Date: Fri, 16 May 2025 09:37:47 -0400 Subject: [PATCH 04/26] Adjust IT bar appearance (#2820) * Adds gap above inline instruction, rounds top corners of IT bar * chore: formatting * chore: formatting * chore: allow moderators to download chats --------- Co-authored-by: ggurdin --- lib/pangea/chat_settings/pages/pangea_chat_details.dart | 4 ++-- lib/pangea/choreographer/widgets/it_bar.dart | 6 +++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/pangea/chat_settings/pages/pangea_chat_details.dart b/lib/pangea/chat_settings/pages/pangea_chat_details.dart index 00240182f..2bc4665a0 100644 --- a/lib/pangea/chat_settings/pages/pangea_chat_details.dart +++ b/lib/pangea/chat_settings/pages/pangea_chat_details.dart @@ -340,7 +340,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, @@ -359,7 +359,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/widgets/it_bar.dart b/lib/pangea/choreographer/widgets/it_bar.dart index 6f02ade8e..57ec5be8c 100644 --- a/lib/pangea/choreographer/widgets/it_bar.dart +++ b/lib/pangea/choreographer/widgets/it_bar.dart @@ -116,9 +116,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: [ From 8289a33c2d35d9b00cc5b94e71a997db9ef8b63f Mon Sep 17 00:00:00 2001 From: ggurdin <46800240+ggurdin@users.noreply.github.com> Date: Fri, 16 May 2025 13:48:18 -0400 Subject: [PATCH 05/26] 2765 direct users to add to chat with multiselect rather than create (#2824) * chore: abstract activity editting into builder widget * feat: allow users to launch activities to existing chats instead of making new chat --- assets/l10n/intl_en.arb | 9 +- lib/config/routes.dart | 15 +- .../activity_generator_view.dart | 15 +- .../activity_planner/activity_plan_card.dart | 729 +++++------ .../activity_planner_builder.dart | 233 ++++ .../activity_planner_page.dart | 8 +- .../activity_planner_page_appbar.dart | 8 +- .../bookmarked_activity_list.dart | 12 +- .../activity_room_selection.dart | 619 ++++++++++ .../activity_suggestion_carousel.dart | 315 ++--- .../activity_suggestion_dialog.dart | 1093 +++++++---------- .../activity_suggestions_area.dart | 104 +- .../suggestions_page.dart | 1 - .../common/widgets/full_width_dialog.dart | 42 +- .../extensions/room_events_extension.dart | 18 +- .../public_spaces/public_spaces_area.dart | 2 +- 16 files changed, 1868 insertions(+), 1355 deletions(-) create mode 100644 lib/pangea/activity_planner/activity_planner_builder.dart create mode 100644 lib/pangea/activity_suggestions/activity_room_selection.dart diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index 9eaee21fc..d610de1dd 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -4936,5 +4936,12 @@ "permissions": "Permissions", "spaceChildPermission": "Who can add new chats and subspaces to this space", "addEnvironmentOverride": "Add environment override", - "defaultOption": "Default" + "defaultOption": "Default", + "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" } diff --git a/lib/config/routes.dart b/lib/config/routes.dart index bc4b0eaa6..d36038ad0 100644 --- a/lib/config/routes.dart +++ b/lib/config/routes.dart @@ -203,12 +203,23 @@ abstract class AppRoutes { ...newRoomRoutes, GoRoute( path: '/planner', - redirect: loggedOutRedirect, pageBuilder: (context, state) => defaultPageBuilder( context, state, - const ActivityGenerator(), + const ActivityPlannerPage(), ), + redirect: loggedOutRedirect, + routes: [ + GoRoute( + path: '/generator', + redirect: loggedOutRedirect, + pageBuilder: (context, state) => defaultPageBuilder( + context, + state, + const ActivityGenerator(), + ), + ), + ], ), ], ), 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_planner_builder.dart b/lib/pangea/activity_planner/activity_planner_builder.dart new file mode 100644 index 000000000..a45521044 --- /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 { + _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..c80eb5726 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("/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..155363c65 --- /dev/null +++ b/lib/pangea/activity_suggestions/activity_room_selection.dart @@ -0,0 +1,619 @@ +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_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, + 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..c2e16f87d 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,445 @@ 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!, - ), - width: width, - height: 200, - cacheKey: _updatedActivity.bookmarkId, - fit: BoxFit.cover, - ) - : CachedNetworkImage( - imageUrl: _updatedActivity.imageURL!, - fit: BoxFit.cover, - placeholder: (context, url) => - const Center( - child: CircularProgressIndicator(), - ), - 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; - } - - 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, - ), - decoration: BoxDecoration( - color: theme.colorScheme.primary - .withAlpha(20), - borderRadius: - BorderRadius.circular(24.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, - ), - ], - ), + 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: [ + 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!, ), - ), - ), - ) - .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, - ), - decoration: BoxDecoration( - color: theme.colorScheme.primary - .withAlpha(20), - borderRadius: - BorderRadius.circular(24.0), - ), - child: Text( - vocab.lemma, - style: theme.textTheme.bodyMedium, - ), - ), - ) - .toList(), - ), - ), - ), + 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 (_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, - ), - maxLines: 1, - onFieldSubmitted: (_) => _addVocab(), + 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, ), ), - 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, - ), + ), + ), + ], + ), + Flexible( + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + spacing: 8.0, + mainAxisSize: MainAxisSize.min, + 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, + ), + ), + 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( + widget.controller.updatedActivity.req + .numberOfParticipants, + ), + style: theme.textTheme.bodyLarge, + ), + ), + if (widget.controller.isEditing) + ActivitySuggestionCardRow( + icon: Symbols.dictionary, + child: ConstrainedBox( + constraints: const BoxConstraints( + maxHeight: 60.0, + ), + child: SingleChildScrollView( + child: Wrap( + spacing: 4.0, + runSpacing: 4.0, + children: widget.controller.vocab + .mapIndexed( + (i, vocab) => Container( + padding: const EdgeInsets + .symmetric( + vertical: 4.0, + horizontal: 8.0, + ), + decoration: BoxDecoration( + color: theme + .colorScheme.primary + .withAlpha(20), + borderRadius: + BorderRadius.circular( + 24.0, + ), + ), + child: MouseRegion( + cursor: SystemMouseCursors + .click, + child: GestureDetector( + onTap: () => widget + .controller + .removeVocab(i), + child: Row( + spacing: 4.0, + mainAxisSize: + MainAxisSize.min, + children: [ + Text(vocab.lemma), + const Icon( + Icons.close, + size: 12.0, + ), + ], + ), + ), + ), + ), + ) + .toList(), + ), + ), + ), + ) + else + ActivitySuggestionCardRow( + icon: Symbols.dictionary, + child: ConstrainedBox( + constraints: const BoxConstraints( + maxHeight: 60.0, + ), + child: SingleChildScrollView( + child: Wrap( + spacing: 4.0, + runSpacing: 4.0, + children: widget.controller.vocab + .map( + (vocab) => Container( + padding: const EdgeInsets + .symmetric( + vertical: 4.0, + horizontal: 8.0, + ), + decoration: BoxDecoration( + color: theme + .colorScheme.primary + .withAlpha(20), + borderRadius: + BorderRadius.circular( + 24.0, + ), + ), + child: Text( + vocab.lemma, + style: theme + .textTheme.bodyMedium, + ), + ), + ) + .toList(), + ), + ), + ), + ), + if (widget.controller.isEditing) + Padding( + padding: const EdgeInsets.symmetric( + vertical: 4.0, + ), + child: Row( + spacing: 4.0, + children: [ + Expanded( + child: TextFormField( + controller: widget + .controller.vocabController, + decoration: InputDecoration( + hintText: L10n.of(context) + .addVocabulary, + ), + maxLines: 1, + onFieldSubmitted: (_) => + widget.controller.addVocab(), + ), + ), + IconButton( + padding: const EdgeInsets.all(0.0), + constraints: + const BoxConstraints(), // override default min size of 48px + iconSize: 16.0, + icon: const Icon(Icons.add_outlined), + onPressed: widget.controller.addVocab, + ), + ], + ), + ), ], ), ), - ], + ), + ), + Padding( + padding: const EdgeInsets.all(16.0), + child: 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, + ), + ), + ), + ) + 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) + GestureDetector( + child: const Icon( + Icons.close_outlined, + size: 16.0, + ), + onTap: () { + 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..27360c8d7 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('/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..45beaaf97 100644 --- a/lib/pangea/activity_suggestions/suggestions_page.dart +++ b/lib/pangea/activity_suggestions/suggestions_page.dart @@ -19,7 +19,6 @@ class SuggestionsPage extends StatelessWidget { vertical: 16.0, ), child: Column( - mainAxisSize: MainAxisSize.min, spacing: 24.0, children: [ if (!isColumnMode) const LearningProgressIndicators(), 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/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/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) From e01166cf14af9228b9d6a3deb34d12145a9347e7 Mon Sep 17 00:00:00 2001 From: ggurdin <46800240+ggurdin@users.noreply.github.com> Date: Fri, 16 May 2025 13:57:37 -0400 Subject: [PATCH 06/26] Sentry (#2825) * fix: resolve typecast error * chore: fix add to stream after close error --- lib/pages/chat/chat.dart | 4 +++- lib/pangea/practice_activities/practice_record_repo.dart | 2 +- lib/pangea/practice_activities/practice_selection_repo.dart | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) 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/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_selection_repo.dart b/lib/pangea/practice_activities/practice_selection_repo.dart index 3e31eb0f6..fc97ed381 100644 --- a/lib/pangea/practice_activities/practice_selection_repo.dart +++ b/lib/pangea/practice_activities/practice_selection_repo.dart @@ -48,7 +48,7 @@ class PracticeSelectionRepo { } static void clean() { - final Iterable keys = _storage.getKeys(); + final keys = _storage.getKeys(); if (keys.length > 300) { final entries = keys .map((key) => _parsePracticeSelection(key)) From 18ebc03ba326ca30fe989acf339afe3713b2533e Mon Sep 17 00:00:00 2001 From: ggurdin <46800240+ggurdin@users.noreply.github.com> Date: Mon, 19 May 2025 09:09:57 -0400 Subject: [PATCH 07/26] chore: fix function for determining if base and target language are the same in initial user settings page (#2828) --- lib/pangea/login/pages/user_settings.dart | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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); From c5b7b550f24be5606875c5e1454e5d87966e4cda Mon Sep 17 00:00:00 2001 From: ggurdin <46800240+ggurdin@users.noreply.github.com> Date: Mon, 19 May 2025 09:24:20 -0400 Subject: [PATCH 08/26] chore: make homepage a subroute of /rooms so that the state of /rooms will be maintained when navigating between them (#2829) --- lib/config/routes.dart | 71 +++++++++---------- .../activity_planner_page_appbar.dart | 2 +- .../activity_suggestions_area.dart | 2 +- lib/pangea/layouts/bottom_nav_layout.dart | 2 +- 4 files changed, 37 insertions(+), 40 deletions(-) diff --git a/lib/config/routes.dart b/lib/config/routes.dart index d36038ad0..d014a23a7 100644 --- a/lib/config/routes.dart +++ b/lib/config/routes.dart @@ -188,43 +188,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', - 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( // Never use a transition on the shell route. Changing the PageBuilder @@ -402,6 +365,40 @@ abstract class AppRoutes { : child, ), routes: [ + // #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# GoRoute( path: 'settings', pageBuilder: (context, state) => defaultPageBuilder( diff --git a/lib/pangea/activity_planner/activity_planner_page_appbar.dart b/lib/pangea/activity_planner/activity_planner_page_appbar.dart index c80eb5726..36dae1223 100644 --- a/lib/pangea/activity_planner/activity_planner_page_appbar.dart +++ b/lib/pangea/activity_planner/activity_planner_page_appbar.dart @@ -70,7 +70,7 @@ class ActivityPlannerPageAppBar extends StatelessWidget customBorder: const CircleBorder(), onTap: () => roomID != null ? context.go('/rooms/$roomID/planner/generator') - : context.go("/homepage/planner/generator"), + : context.go("/rooms/homepage/planner/generator"), child: Container( decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHighest, diff --git a/lib/pangea/activity_suggestions/activity_suggestions_area.dart b/lib/pangea/activity_suggestions/activity_suggestions_area.dart index 27360c8d7..e7c3335d3 100644 --- a/lib/pangea/activity_suggestions/activity_suggestions_area.dart +++ b/lib/pangea/activity_suggestions/activity_suggestions_area.dart @@ -180,7 +180,7 @@ class ActivitySuggestionsAreaState extends State { ), IconButton( icon: const Icon(Icons.menu_outlined), - onPressed: () => context.go('/homepage/planner'), + onPressed: () => context.go('/rooms/homepage/planner'), tooltip: L10n.of(context).activityPlannerTitle, ), ], diff --git a/lib/pangea/layouts/bottom_nav_layout.dart b/lib/pangea/layouts/bottom_nav_layout.dart index 4bd7ee99a..3332a673d 100644 --- a/lib/pangea/layouts/bottom_nav_layout.dart +++ b/lib/pangea/layouts/bottom_nav_layout.dart @@ -43,7 +43,7 @@ class BottomNavBarState extends State { void onItemTapped(int index) { switch (index) { case 0: - context.go('/homepage'); + context.go('/rooms/homepage'); break; case 1: context.go('/rooms'); From 3359cfe25d4661e853bb9f067b5a4670e3c9c152 Mon Sep 17 00:00:00 2001 From: ggurdin <46800240+ggurdin@users.noreply.github.com> Date: Mon, 19 May 2025 12:19:17 -0400 Subject: [PATCH 09/26] fix: make TTS button pause when it's stopped by the other TTS button playing (#2831) --- lib/pages/chat/events/message_content.dart | 3 +- .../vocab_analytics_details_view.dart | 7 +- .../controllers/choreographer.dart | 4 +- .../choreographer/widgets/choice_array.dart | 11 +- .../choreographer/widgets/igc/span_card.dart | 5 +- lib/pangea/choreographer/widgets/it_bar.dart | 1 - .../pages/settings_learning.dart | 5 +- .../toolbar/controllers/tts_controller.dart | 141 +++++++++++------- .../practice_match_item.dart | 7 +- .../widgets/message_selection_overlay.dart | 3 +- .../multiple_choice_activity.dart | 10 +- .../practice_activity_card.dart | 3 +- .../practice_activity/word_audio_button.dart | 80 +++++----- .../word_text_with_audio_button.dart | 139 +++-------------- .../widgets/reading_assistance_content.dart | 5 - .../widgets/word_zoom/lemma_widget.dart | 3 - .../widgets/word_zoom/word_zoom_widget.dart | 6 +- lib/widgets/matrix.dart | 2 + 18 files changed, 174 insertions(+), 261 deletions(-) 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/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/choreographer/controllers/choreographer.dart b/lib/pangea/choreographer/controllers/choreographer.dart index da59e978d..301fda833 100644 --- a/lib/pangea/choreographer/controllers/choreographer.dart +++ b/lib/pangea/choreographer/controllers/choreographer.dart @@ -43,7 +43,6 @@ class Choreographer { late ITController itController; late IgcController igc; late ErrorService errorService; - late TtsController tts; bool isFetching = false; int _timesClicked = 0; @@ -64,7 +63,6 @@ class Choreographer { _initialize(); } _initialize() { - tts = TtsController(chatController: chatController); _textController = PangeaTextController(choreographer: this); InputPasteListener(_textController, onPaste); itController = ITController(this); @@ -566,7 +564,7 @@ class Choreographer { _textController.dispose(); _languageStream?.cancel(); stateStream.close(); - tts.dispose(); + TtsController.stop(); } LanguageModel? get l2Lang { 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/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 57ec5be8c..085156c54 100644 --- a/lib/pangea/choreographer/widgets/it_bar.dart +++ b/lib/pangea/choreographer/widgets/it_bar.dart @@ -418,7 +418,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(), ); 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/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/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/widgets/matrix.dart b/lib/widgets/matrix.dart index 3f1673033..7c071fbc6 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# } From e5e7f40220b6fc8e5759a268354dffb35bd993ac Mon Sep 17 00:00:00 2001 From: ggurdin <46800240+ggurdin@users.noreply.github.com> Date: Mon, 19 May 2025 12:57:20 -0400 Subject: [PATCH 10/26] chore: some fixes for activity message overlay not matching underlying message (#2834) --- lib/pangea/activity_planner/activity_plan_message.dart | 7 ------- lib/pangea/toolbar/widgets/overlay_message.dart | 8 +++++--- 2 files changed, 5 insertions(+), 10 deletions(-) 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/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 From 3adac7f63cdfaae83477c431d04063a7d932e50e Mon Sep 17 00:00:00 2001 From: ggurdin <46800240+ggurdin@users.noreply.github.com> Date: Tue, 20 May 2025 09:46:44 -0400 Subject: [PATCH 11/26] chore: use display event ID as token position cache key to prevent out-of-date positions being used to render edited messages (#2844) --- lib/pangea/events/utils/message_text_util.dart | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) 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) { From 75f70c7fdbe97267f72e7f5d7618f621c2b0cd22 Mon Sep 17 00:00:00 2001 From: ggurdin <46800240+ggurdin@users.noreply.github.com> Date: Tue, 20 May 2025 12:50:29 -0400 Subject: [PATCH 12/26] 2843 timeout on igc (#2846) * chore: add timeout to tokenization call after message send * chore: add timeout to IGC * chore: add timeout to IT --- lib/pages/chat/chat_view.dart | 7 +- .../widgets/chat_floating_action_button.dart | 3 +- lib/pangea/chat/widgets/chat_input_bar.dart | 22 ++++- .../controllers/choreographer.dart | 97 +++++++++---------- .../controllers/igc_controller.dart | 4 +- .../controllers/it_controller.dart | 5 +- lib/pangea/choreographer/widgets/it_bar.dart | 66 ++++++++----- .../choreographer/widgets/it_bar_buttons.dart | 27 ------ .../controllers/message_data_controller.dart | 9 +- .../pangea_representation_event.dart | 4 +- lib/pangea/events/repo/token_api_models.dart | 5 +- 11 files changed, 130 insertions(+), 119 deletions(-) delete mode 100644 lib/pangea/choreographer/widgets/it_bar_buttons.dart 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/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/choreographer/controllers/choreographer.dart b/lib/pangea/choreographer/controllers/choreographer.dart index 301fda833..9be056e3a 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'; @@ -148,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() { diff --git a/lib/pangea/choreographer/controllers/igc_controller.dart b/lib/pangea/choreographer/controllers/igc_controller.dart index cecf03ea0..76eaed5cb 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) { 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/widgets/it_bar.dart b/lib/pangea/choreographer/widgets/it_bar.dart index 085156c54..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'; @@ -206,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( @@ -391,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(), @@ -438,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/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..7e10fd14b 100644 --- a/lib/pangea/events/event_wrappers/pangea_representation_event.dart +++ b/lib/pangea/events/event_wrappers/pangea_representation_event.dart @@ -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 From 48a951c1a51c97b973f11edbd40da0a5c025346c Mon Sep 17 00:00:00 2001 From: Kelrap <99418823+Kelrap@users.noreply.github.com> Date: Tue, 20 May 2025 12:56:20 -0400 Subject: [PATCH 13/26] Match public space card and public room bottom sheet description appearance (#2845) * Match public space card and public room bottom sheet description appearance * chore: formatting --------- Co-authored-by: ggurdin --- lib/pangea/public_spaces/public_room_bottom_sheet.dart | 2 ++ lib/pangea/public_spaces/public_space_card.dart | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/pangea/public_spaces/public_room_bottom_sheet.dart b/lib/pangea/public_spaces/public_room_bottom_sheet.dart index 6d0ca3ca3..2a5381fc4 100644 --- a/lib/pangea/public_spaces/public_room_bottom_sheet.dart +++ b/lib/pangea/public_spaces/public_room_bottom_sheet.dart @@ -208,6 +208,7 @@ class PublicRoomBottomSheetState extends State { child: Column( spacing: 16.0, mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( spacing: 8.0, @@ -226,6 +227,7 @@ class PublicRoomBottomSheetState extends State { child: Text( chunk!.topic!, softWrap: true, + textAlign: TextAlign.start, maxLines: null, ), ), diff --git a/lib/pangea/public_spaces/public_space_card.dart b/lib/pangea/public_spaces/public_space_card.dart index a4873e65d..8d1c58ddf 100644 --- a/lib/pangea/public_spaces/public_space_card.dart +++ b/lib/pangea/public_spaces/public_space_card.dart @@ -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( From 47ef0813a39880e1284f05621ca0f27e4596c065 Mon Sep 17 00:00:00 2001 From: Kelrap <99418823+Kelrap@users.noreply.github.com> Date: Tue, 20 May 2025 13:01:18 -0400 Subject: [PATCH 14/26] Cleaned up name submission criteria (#2835) * Cleaned up name submission criteria * chore: formatting --------- Co-authored-by: ggurdin --- lib/pages/new_group/new_group.dart | 9 ++++++--- lib/pages/new_group/new_group_view.dart | 6 +++--- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/lib/pages/new_group/new_group.dart b/lib/pages/new_group/new_group.dart index 4e9d4cc57..bcb397fd7 100644 --- a/lib/pages/new_group/new_group.dart +++ b/lib/pages/new_group/new_group.dart @@ -48,6 +48,8 @@ class NewGroupController extends State { bool requiredCodeToJoin = false; // bool publicGroup = false; + + bool get canSubmit => nameController.text.trim().isNotEmpty; // Pangea# bool groupCanBeFound = false; @@ -250,10 +252,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; } 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# ), From fe2db3264dbf8f7ef03ddad359d2ba88a5dbdaf9 Mon Sep 17 00:00:00 2001 From: ggurdin <46800240+ggurdin@users.noreply.github.com> Date: Tue, 20 May 2025 13:10:41 -0400 Subject: [PATCH 15/26] chore: new logic for wrapping tokens in HTML messages (#2848) --- lib/pages/chat/events/html_message.dart | 174 +++++++++++++++++------- 1 file changed, 127 insertions(+), 47 deletions(-) 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, From a5539b4bead4276fb126bae4bd0a06ad3971fe7a Mon Sep 17 00:00:00 2001 From: ggurdin <46800240+ggurdin@users.noreply.github.com> Date: Tue, 20 May 2025 16:45:58 -0400 Subject: [PATCH 16/26] 2655 tweaks to spacechat navigation (#2859) * chore: update parent space navigation * refactor: show space navigation rail on mobile --- assets/l10n/intl_en.arb | 3 +- lib/config/app_config.dart | 3 + lib/config/routes.dart | 87 +++--- lib/config/setting_keys.dart | 4 + lib/pages/chat_list/chat_list_body.dart | 3 +- lib/pages/chat_list/chat_list_view.dart | 9 +- lib/pages/chat_list/navi_rail_item.dart | 9 +- lib/pages/chat_list/space_view.dart | 167 +++++++---- lib/pages/settings/settings_view.dart | 6 +- .../settings_style/settings_style_view.dart | 8 + .../suggestions_page.dart | 57 ++-- .../learning_progress_indicators.dart | 265 +++++++++--------- lib/pangea/analytics_summary/level_badge.dart | 5 +- .../analytics_summary/progress_indicator.dart | 20 +- lib/pangea/layouts/bottom_nav_layout.dart | 96 ------- lib/widgets/matrix.dart | 6 + lib/widgets/navigation_rail.dart | 51 +++- 17 files changed, 422 insertions(+), 377 deletions(-) delete mode 100644 lib/pangea/layouts/bottom_nav_layout.dart diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index d610de1dd..68fc0b0da 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -4943,5 +4943,6 @@ "launchActivityToChats": "Launch activity to chats", "searchChats": "Search chats", "selectChats": "Select chats", - "selectChatToStart": "Complete! Select a chat to start" + "selectChatToStart": "Complete! Select a chat to start", + "displayNavigationRail": "Show navigation rail on mobile" } diff --git a/lib/config/app_config.dart b/lib/config/app_config.dart index 33a97c5cc..d04d20be0 100644 --- a/lib/config/app_config.dart +++ b/lib/config/app_config.dart @@ -134,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 d014a23a7..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'; @@ -210,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( @@ -352,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( @@ -365,40 +389,6 @@ abstract class AppRoutes { : child, ), routes: [ - // #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# GoRoute( path: 'settings', pageBuilder: (context, state) => defaultPageBuilder( @@ -802,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_list/chat_list_body.dart b/lib/pages/chat_list/chat_list_body.dart index 39e08dab3..ec4280601 100644 --- a/lib/pages/chat_list/chat_list_body.dart +++ b/lib/pages/chat_list/chat_list_body.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, ] 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 24533cc31..879a57213 100644 --- a/lib/pages/chat_list/space_view.dart +++ b/lib/pages/chat_list/space_view.dart @@ -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/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_suggestions/suggestions_page.dart b/lib/pangea/activity_suggestions/suggestions_page.dart index 45beaaf97..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,24 +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( - 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_summary/learning_progress_indicators.dart b/lib/pangea/analytics_summary/learning_progress_indicators.dart index ac28bc546..440a8a805 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 < 380, + ), + ) + .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 < 380, + ), + ), + ], + ), + ), + ), + ), + ], + ), + ), + ], + ); + }, ); } } diff --git a/lib/pangea/analytics_summary/level_badge.dart b/lib/pangea/analytics_summary/level_badge.dart index 1282c1c41..805f7518c 100644 --- a/lib/pangea/analytics_summary/level_badge.dart +++ b/lib/pangea/analytics_summary/level_badge.dart @@ -8,8 +8,11 @@ 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, }); @@ -46,7 +49,7 @@ class LevelBadge extends StatelessWidget { ), const SizedBox(width: 4), Text( - L10n.of(context).levelShort(level), + mini ? "$level" : L10n.of(context).levelShort(level), style: TextStyle( fontSize: 12, fontWeight: FontWeight.bold, 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/layouts/bottom_nav_layout.dart b/lib/pangea/layouts/bottom_nav_layout.dart deleted file mode 100644 index 3332a673d..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('/rooms/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/widgets/matrix.dart b/lib/widgets/matrix.dart index 7c071fbc6..1dea15a8d 100644 --- a/lib/widgets/matrix.dart +++ b/lib/widgets/matrix.dart @@ -484,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..3e04d61b9 100644 --- a/lib/widgets/navigation_rail.dart +++ b/lib/widgets/navigation_rail.dart @@ -34,6 +34,14 @@ class SpacesNavigationRail extends StatelessWidget { .uri .path .startsWith('/rooms/settings'); + // #Pangea + final isHomepage = GoRouter.of(context) + .routeInformationProvider + .value + .uri + .path + .contains('homepage'); + // Pangea# return StreamBuilder( key: ValueKey( client.userID.toString(), @@ -53,7 +61,12 @@ class SpacesNavigationRail extends StatelessWidget { .toList(); return SizedBox( - width: FluffyThemes.navRailWidth, + // #Pangea + // width: FluffyThemes.navRailWidth, + width: FluffyThemes.isColumnMode(context) + ? FluffyThemes.navRailWidth + : FluffyThemes.navRailWidth * 0.75, + // Pangea# child: Column( children: [ Expanded( @@ -61,31 +74,45 @@ 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: isHomepage, + onTap: () => 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, + unreadBadgeFilter: (room) => true, + ); + } + i--; + // Pangea# + if (i == 0) { + return 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, ); } From 8d5429771c7bd624746b4769031554778be72597 Mon Sep 17 00:00:00 2001 From: ggurdin <46800240+ggurdin@users.noreply.github.com> Date: Tue, 20 May 2025 17:07:22 -0400 Subject: [PATCH 17/26] chore: scroll activity dialog image with other content (#2860) --- .../activity_planner_builder.dart | 2 +- .../activity_suggestion_dialog.dart | 656 +++++++++--------- 2 files changed, 346 insertions(+), 312 deletions(-) diff --git a/lib/pangea/activity_planner/activity_planner_builder.dart b/lib/pangea/activity_planner/activity_planner_builder.dart index a45521044..36e72c0c9 100644 --- a/lib/pangea/activity_planner/activity_planner_builder.dart +++ b/lib/pangea/activity_planner/activity_planner_builder.dart @@ -211,7 +211,7 @@ class ActivityPlannerBuilderState extends State { } Future clearEdits() async { - _resetActivity(); + await _resetActivity(); if (mounted) { setState(() { isEditing = false; diff --git a/lib/pangea/activity_suggestions/activity_suggestion_dialog.dart b/lib/pangea/activity_suggestions/activity_suggestion_dialog.dart index c2e16f87d..3a1282b3f 100644 --- a/lib/pangea/activity_suggestions/activity_suggestion_dialog.dart +++ b/lib/pangea/activity_suggestions/activity_suggestion_dialog.dart @@ -86,320 +86,350 @@ class ActivitySuggestionDialogState extends State { mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.spaceBetween, 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, - ), - ), - ), - ), - ], - ), Flexible( child: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - spacing: 8.0, - mainAxisSize: MainAxisSize.min, - 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, - ), - ), - 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( - widget.controller.updatedActivity.req - .numberOfParticipants, - ), - style: theme.textTheme.bodyLarge, - ), - ), - if (widget.controller.isEditing) - ActivitySuggestionCardRow( - icon: Symbols.dictionary, - child: ConstrainedBox( - constraints: const BoxConstraints( - maxHeight: 60.0, - ), - child: SingleChildScrollView( - child: Wrap( - spacing: 4.0, - runSpacing: 4.0, - children: widget.controller.vocab - .mapIndexed( - (i, vocab) => Container( - padding: const EdgeInsets - .symmetric( - vertical: 4.0, - horizontal: 8.0, - ), - decoration: BoxDecoration( - color: theme - .colorScheme.primary - .withAlpha(20), - borderRadius: - BorderRadius.circular( - 24.0, - ), - ), - child: MouseRegion( - cursor: SystemMouseCursors - .click, - child: GestureDetector( - onTap: () => widget + 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 - .removeVocab(i), - child: Row( - spacing: 4.0, - mainAxisSize: - MainAxisSize.min, - children: [ - Text(vocab.lemma), - const Icon( - Icons.close, - size: 12.0, - ), - ], - ), + .updatedActivity + .imageURL!, ), - ), - ), - ) - .toList(), - ), - ), - ), - ) - else - ActivitySuggestionCardRow( - icon: Symbols.dictionary, - child: ConstrainedBox( - constraints: const BoxConstraints( - maxHeight: 60.0, - ), - child: SingleChildScrollView( - child: Wrap( - spacing: 4.0, - runSpacing: 4.0, - children: widget.controller.vocab - .map( - (vocab) => Container( - padding: const EdgeInsets - .symmetric( - vertical: 4.0, - horizontal: 8.0, - ), - decoration: BoxDecoration( - color: theme - .colorScheme.primary - .withAlpha(20), - borderRadius: - BorderRadius.circular( - 24.0, + 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(), ), - ), - child: Text( - vocab.lemma, - style: theme - .textTheme.bodyMedium, - ), - ), - ) - .toList(), - ), - ), - ), + errorWidget: ( + context, + url, + error, + ) => + const SizedBox(), + ) + : null, ), - 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(), + 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, ), ), - IconButton( - padding: const EdgeInsets.all(0.0), - constraints: - const BoxConstraints(), // override default min size of 48px - iconSize: 16.0, - icon: const Icon(Icons.add_outlined), - onPressed: widget.controller.addVocab, - ), - ], + ), ), - ), - ], - ), + ], + ), + Padding( + padding: const EdgeInsets.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, + ), + ), + 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( + widget.controller.updatedActivity + .req.numberOfParticipants, + ), + style: theme.textTheme.bodyLarge, + ), + ), + if (widget.controller.isEditing) + ActivitySuggestionCardRow( + icon: Symbols.dictionary, + child: ConstrainedBox( + constraints: const BoxConstraints( + maxHeight: 60.0, + ), + child: SingleChildScrollView( + child: Wrap( + spacing: 4.0, + runSpacing: 4.0, + children: widget.controller.vocab + .mapIndexed( + (i, vocab) => Container( + padding: const EdgeInsets + .symmetric( + vertical: 4.0, + horizontal: 8.0, + ), + decoration: BoxDecoration( + color: theme + .colorScheme.primary + .withAlpha(20), + borderRadius: + BorderRadius + .circular( + 24.0, + ), + ), + child: MouseRegion( + cursor: + SystemMouseCursors + .click, + child: GestureDetector( + onTap: () => widget + .controller + .removeVocab(i), + child: Row( + spacing: 4.0, + mainAxisSize: + MainAxisSize + .min, + children: [ + Text(vocab.lemma), + const Icon( + Icons.close, + size: 12.0, + ), + ], + ), + ), + ), + ), + ) + .toList(), + ), + ), + ), + ) + else + ActivitySuggestionCardRow( + icon: Symbols.dictionary, + child: ConstrainedBox( + constraints: const BoxConstraints( + maxHeight: 60.0, + ), + child: SingleChildScrollView( + child: Wrap( + spacing: 4.0, + runSpacing: 4.0, + children: widget.controller.vocab + .map( + (vocab) => Container( + padding: const EdgeInsets + .symmetric( + vertical: 4.0, + horizontal: 8.0, + ), + decoration: BoxDecoration( + color: theme + .colorScheme.primary + .withAlpha(20), + borderRadius: + BorderRadius + .circular( + 24.0, + ), + ), + child: Text( + vocab.lemma, + style: theme.textTheme + .bodyMedium, + ), + ), + ) + .toList(), + ), + ), + ), + ), + if (widget.controller.isEditing) + Padding( + padding: const EdgeInsets.symmetric( + vertical: 4.0, + ), + child: Row( + spacing: 4.0, + children: [ + Expanded( + child: TextFormField( + controller: widget + .controller.vocabController, + decoration: InputDecoration( + hintText: L10n.of(context) + .addVocabulary, + ), + maxLines: 1, + onFieldSubmitted: (_) => widget + .controller + .addVocab(), + ), + ), + IconButton( + padding: + const EdgeInsets.all(0.0), + constraints: + const BoxConstraints(), // override default min size of 48px + iconSize: 16.0, + icon: const Icon( + Icons.add_outlined, + ), + onPressed: + widget.controller.addVocab, + ), + ], + ), + ), + ], + ), + ), + ], ), ), ), @@ -459,13 +489,17 @@ class ActivitySuggestionDialogState extends State { ), ), if (widget.controller.isEditing) - GestureDetector( - child: const Icon( - Icons.close_outlined, - size: 16.0, + IconButton.filled( + style: IconButton.styleFrom( + backgroundColor: theme.colorScheme.primary, ), - onTap: () { - widget.controller.clearEdits(); + 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); }, ) From 871c9303f8091496a7af59e785688a47a903abb5 Mon Sep 17 00:00:00 2001 From: Kelrap <99418823+Kelrap@users.noreply.github.com> Date: Wed, 21 May 2025 09:25:32 -0400 Subject: [PATCH 18/26] Analytics appearance tweaks (#2856) * Use participants star for level bar * Remove progress bar drop shadow --------- Co-authored-by: ggurdin --- .../learning_progress_indicators.dart | 4 +-- lib/pangea/analytics_summary/level_badge.dart | 31 +++++-------------- .../analytics_summary/level_bar_popup.dart | 28 +++++------------ .../progress_bar/animated_level_dart.dart | 8 ----- 4 files changed, 16 insertions(+), 55 deletions(-) diff --git a/lib/pangea/analytics_summary/learning_progress_indicators.dart b/lib/pangea/analytics_summary/learning_progress_indicators.dart index 440a8a805..76d07588a 100644 --- a/lib/pangea/analytics_summary/learning_progress_indicators.dart +++ b/lib/pangea/analytics_summary/learning_progress_indicators.dart @@ -209,7 +209,7 @@ class LearningProgressIndicatorsState ); }, indicator: c.indicator, - mini: constraints.maxWidth < 380, + mini: constraints.maxWidth < 300, ), ) .toList(), @@ -241,7 +241,7 @@ class LearningProgressIndicatorsState left: 0, child: LevelBadge( level: _constructsModel.level, - mini: constraints.maxWidth < 380, + mini: constraints.maxWidth < 300, ), ), ], diff --git a/lib/pangea/analytics_summary/level_badge.dart b/lib/pangea/analytics_summary/level_badge.dart index 805f7518c..f1bd6da1b 100644 --- a/lib/pangea/analytics_summary/level_badge.dart +++ b/lib/pangea/analytics_summary/level_badge.dart @@ -2,7 +2,6 @@ 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'; @@ -34,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( - mini ? "$level" : 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( From c45415749b2f3f5b506665c6f0657c1e2186c6f3 Mon Sep 17 00:00:00 2001 From: ggurdin <46800240+ggurdin@users.noreply.github.com> Date: Wed, 21 May 2025 09:36:56 -0400 Subject: [PATCH 19/26] chore: add new default power levels (#2855) * chore: add new default power levels * chore: update permissions copy --- assets/l10n/intl_en.arb | 10 +- .../chat_permissions_settings_view.dart | 121 +++++++++--------- .../permission_list_tile.dart | 44 ++++++- .../activity_room_selection.dart | 8 ++ .../chat/constants/default_power_level.dart | 58 ++++++--- .../common/controllers/pangea_controller.dart | 8 ++ .../pangea_representation_event.dart | 2 +- .../spaces/utils/client_spaces_extension.dart | 12 +- 8 files changed, 170 insertions(+), 93 deletions(-) diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index 68fc0b0da..d51ebb769 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -4944,5 +4944,13 @@ "searchChats": "Search chats", "selectChats": "Select chats", "selectChatToStart": "Complete! Select a chat to start", - "displayNavigationRail": "Show navigation rail on mobile" + "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" } 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/pangea/activity_suggestions/activity_room_selection.dart b/lib/pangea/activity_suggestions/activity_room_selection.dart index 155363c65..a2523b7d1 100644 --- a/lib/pangea/activity_suggestions/activity_room_selection.dart +++ b/lib/pangea/activity_suggestions/activity_room_selection.dart @@ -14,6 +14,7 @@ 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'; @@ -178,6 +179,13 @@ class ActivityRoomSelectionState extends State { 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, 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/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/events/event_wrappers/pangea_representation_event.dart b/lib/pangea/events/event_wrappers/pangea_representation_event.dart index 7e10fd14b..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: diff --git a/lib/pangea/spaces/utils/client_spaces_extension.dart b/lib/pangea/spaces/utils/client_spaces_extension.dart index 8f6f6e759..055485339 100644 --- a/lib/pangea/spaces/utils/client_spaces_extension.dart +++ b/lib/pangea/spaces/utils/client_spaces_extension.dart @@ -24,6 +24,7 @@ extension SpacesClientExtension on Client { powerLevelContentOverride: {'events_default': 100}, initialState: [ ..._spaceInitialState( + userID!, joinCode, joinRules: joinRules, ), @@ -123,6 +124,7 @@ extension SpacesClientExtension on Client { } List _spaceInitialState( + String userID, String joinCode, { required JoinRules joinRules, }) { @@ -130,15 +132,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, From 21d703d640d0465bd6b5223c676dc9c803c216d5 Mon Sep 17 00:00:00 2001 From: ggurdin <46800240+ggurdin@users.noreply.github.com> Date: Wed, 21 May 2025 10:10:13 -0400 Subject: [PATCH 20/26] chore: wait for space default rooms in sync before adding them to space (#2861) --- assets/l10n/intl_en.arb | 4 +- lib/pages/new_group/new_group.dart | 27 ++-------- .../spaces/utils/client_spaces_extension.dart | 15 +++++- lib/widgets/navigation_rail.dart | 50 +++++++++++-------- 4 files changed, 51 insertions(+), 45 deletions(-) diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index d51ebb769..53efbf38c 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -4952,5 +4952,7 @@ "inviteOtherUsersToRoom": "Invite other users", "changeTheNameOfTheSpace": "Change the name of the space", "changeTheDescription": "Change the description", - "changeThePermissions": "Change the permissions" + "changeThePermissions": "Change the permissions", + "introductions": "Introductions", + "announcements": "Announcements" } diff --git a/lib/pages/new_group/new_group.dart b/lib/pages/new_group/new_group.dart index bcb397fd7..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'; @@ -188,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 { @@ -220,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: @@ -237,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# } @@ -273,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/pangea/spaces/utils/client_spaces_extension.dart b/lib/pangea/spaces/utils/client_spaces_extension.dart index 055485339..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, @@ -39,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; } @@ -109,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], ); diff --git a/lib/widgets/navigation_rail.dart b/lib/widgets/navigation_rail.dart index 3e04d61b9..7ae9665b5 100644 --- a/lib/widgets/navigation_rail.dart +++ b/lib/widgets/navigation_rail.dart @@ -41,6 +41,7 @@ class SpacesNavigationRail extends StatelessWidget { .uri .path .contains('homepage'); + final isColumnMode = FluffyThemes.isColumnMode(context); // Pangea# return StreamBuilder( key: ValueKey( @@ -63,7 +64,7 @@ class SpacesNavigationRail extends StatelessWidget { return SizedBox( // #Pangea // width: FluffyThemes.navRailWidth, - width: FluffyThemes.isColumnMode(context) + width: isColumnMode ? FluffyThemes.navRailWidth : FluffyThemes.navRailWidth * 0.75, // Pangea# @@ -80,8 +81,12 @@ class SpacesNavigationRail extends StatelessWidget { // #Pangea if (i == 0) { return NaviRailItem( - isSelected: isHomepage, - onTap: () => context.go("/rooms/homepage"), + isSelected: isColumnMode + ? activeSpaceId == null && !isSettings + : isHomepage, + onTap: () => isColumnMode + ? onGoToChats() + : context.go("/rooms/homepage"), icon: const Padding( padding: EdgeInsets.all(10.0), child: Icon(Icons.home_outlined), @@ -97,24 +102,27 @@ class SpacesNavigationRail extends StatelessWidget { i--; // Pangea# if (i == 0) { - return 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, - ); + 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) { From ddc60b0a4ff2abbc530ff970fd18dd1cdcf774a2 Mon Sep 17 00:00:00 2001 From: ggurdin <46800240+ggurdin@users.noreply.github.com> Date: Wed, 21 May 2025 16:06:30 -0400 Subject: [PATCH 21/26] chore: fully update match info after auto-accepting replacement, add more error handling in construct token span (#2865) --- .../controllers/choreographer.dart | 18 +++-- .../controllers/error_service.dart | 10 +++ .../controllers/igc_controller.dart | 3 + .../models/igc_text_data_model.dart | 65 +++++++++++++++---- .../widgets/igc/pangea_text_controller.dart | 30 ++++++--- 5 files changed, 98 insertions(+), 28 deletions(-) diff --git a/lib/pangea/choreographer/controllers/choreographer.dart b/lib/pangea/choreographer/controllers/choreographer.dart index 9be056e3a..ef5364270 100644 --- a/lib/pangea/choreographer/controllers/choreographer.dart +++ b/lib/pangea/choreographer/controllers/choreographer.dart @@ -453,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( @@ -465,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, 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 76eaed5cb..8af6dc802 100644 --- a/lib/pangea/choreographer/controllers/igc_controller.dart +++ b/lib/pangea/choreographer/controllers/igc_controller.dart @@ -295,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/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/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), ], ); From 2ad57fb69bdb4218ac47c23d18b3d902814f27f2 Mon Sep 17 00:00:00 2001 From: Kelrap Date: Thu, 22 May 2025 12:43:38 -0400 Subject: [PATCH 22/26] For user replies in dark mode, make replied message sender name darker for readability --- lib/pages/chat/events/reply_content.dart | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/pages/chat/events/reply_content.dart b/lib/pages/chat/events/reply_content.dart index 82584bafd..372948cd4 100644 --- a/lib/pages/chat/events/reply_content.dart +++ b/lib/pages/chat/events/reply_content.dart @@ -1,9 +1,8 @@ +import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart'; import 'package:flutter/material.dart'; - import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:matrix/matrix.dart'; -import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart'; import '../../../config/app_config.dart'; class ReplyContent extends StatelessWidget { @@ -69,7 +68,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, ), From 9d84082ac85ff814f06f27cb9d2d853e07e81ac1 Mon Sep 17 00:00:00 2001 From: Kelrap Date: Thu, 22 May 2025 12:53:25 -0400 Subject: [PATCH 23/26] Darken decoration bar left of reply on light background --- lib/pages/chat/events/reply_content.dart | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/lib/pages/chat/events/reply_content.dart b/lib/pages/chat/events/reply_content.dart index 372948cd4..24421c9cb 100644 --- a/lib/pages/chat/events/reply_content.dart +++ b/lib/pages/chat/events/reply_content.dart @@ -31,10 +31,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, From e96a16b297c8d9b69987ce8cac443fa17614cd9c Mon Sep 17 00:00:00 2001 From: ggurdin <46800240+ggurdin@users.noreply.github.com> Date: Thu, 22 May 2025 13:00:42 -0400 Subject: [PATCH 24/26] =?UTF-8?q?chore:=20fully=20update=20match=20info=20?= =?UTF-8?q?after=20auto-accepting=20replacement,=20add=20=E2=80=A6=20(#286?= =?UTF-8?q?6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: fully update match info after auto-accepting replacement, add more error handling in construct token span * bump version * fix: don't stop activity language on fail to fetch image URL * fix: don't show copy class code buttons into class code is null * fix: use activity type enum name in key instead of string --- lib/pages/chat/chat.dart | 11 +- .../invitation_selection_view.dart | 160 +++++++++--------- lib/pages/new_group/new_group.dart | 5 +- .../activity_planner/activity_plan_card.dart | 25 ++- .../widgets/class_invitation_buttons.dart | 5 +- .../controllers/choreographer.dart | 18 +- .../controllers/error_service.dart | 10 ++ .../controllers/igc_controller.dart | 3 + .../models/igc_text_data_model.dart | 65 +++++-- .../widgets/igc/pangea_text_controller.dart | 30 +++- .../room_space_settings_extension.dart | 6 +- .../activity_type_enum.dart | 19 --- .../message_activity_request.dart | 2 +- .../practice_activity_model.dart | 2 +- .../practice_selection_repo.dart | 2 +- .../practice_activities/practice_target.dart | 17 +- pubspec.yaml | 2 +- 17 files changed, 236 insertions(+), 146 deletions(-) diff --git a/lib/pages/chat/chat.dart b/lib/pages/chat/chat.dart index 754a9df95..815682d7c 100644 --- a/lib/pages/chat/chat.dart +++ b/lib/pages/chat/chat.dart @@ -867,10 +867,13 @@ class ChatController extends State pangeaEditingEvent = previousEdit; } - GoogleAnalytics.sendMessage( - room.id, - room.classCode(context), - ); + final spaceCode = room.classCode(context); + if (spaceCode != null) { + GoogleAnalytics.sendMessage( + room.id, + spaceCode, + ); + } if (msgEventId == null) { ErrorHandler.logError( diff --git a/lib/pages/invitation_selection/invitation_selection_view.dart b/lib/pages/invitation_selection/invitation_selection_view.dart index a27148870..0877cc645 100644 --- a/lib/pages/invitation_selection/invitation_selection_view.dart +++ b/lib/pages/invitation_selection/invitation_selection_view.dart @@ -63,98 +63,104 @@ class InvitationSelectionView extends StatelessWidget { child: Column( children: [ // #Pangea - Padding( - padding: const EdgeInsets.all(16.0), - child: InkWell( - customBorder: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(99), - ), - onTap: () async { - await Clipboard.setData( - ClipboardData(text: room.classCode(context)), - ); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(L10n.of(context).copiedToClipboard)), - ); - }, - child: Container( - padding: const EdgeInsets.symmetric( - vertical: 12.0, - horizontal: 16.0, - ), - decoration: BoxDecoration( - color: theme.colorScheme.secondaryContainer, + if (room.isSpace && room.classCode(context) != null) + Padding( + padding: const EdgeInsets.all(16.0), + child: InkWell( + customBorder: RoundedRectangleBorder( borderRadius: BorderRadius.circular(99), ), - child: Row( - spacing: 16.0, - children: [ - const Icon( - Icons.copy_outlined, - size: 20.0, + onTap: () async { + await Clipboard.setData( + ClipboardData(text: room.classCode(context)!), + ); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(L10n.of(context).copiedToClipboard), ), - Text( - "${L10n.of(context).copyClassCode}: ${room.classCode(context)}", - style: TextStyle( - color: theme.colorScheme.onPrimaryContainer, - fontWeight: FontWeight.normal, - fontSize: 16.0, + ); + }, + child: Container( + padding: const EdgeInsets.symmetric( + vertical: 12.0, + horizontal: 16.0, + ), + decoration: BoxDecoration( + color: theme.colorScheme.secondaryContainer, + borderRadius: BorderRadius.circular(99), + ), + child: Row( + spacing: 16.0, + children: [ + const Icon( + Icons.copy_outlined, + size: 20.0, ), - ), - ], + Text( + "${L10n.of(context).copyClassCode}: ${room.classCode(context)}", + style: TextStyle( + color: theme.colorScheme.onPrimaryContainer, + fontWeight: FontWeight.normal, + fontSize: 16.0, + ), + ), + ], + ), ), ), ), - ), - Padding( - padding: const EdgeInsets.only( - bottom: 16.0, - left: 16.0, - right: 16.0, - ), - child: InkWell( - customBorder: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(99), + if (room.isSpace && room.classCode(context) != null) + Padding( + padding: const EdgeInsets.only( + bottom: 16.0, + left: 16.0, + right: 16.0, ), - onTap: () async { - final String initialUrl = - kIsWeb ? html.window.origin! : Environment.frontendURL; - final link = - "$initialUrl/#/join_with_link?${SpaceConstants.classCode}=${room.classCode(context)}"; - await Clipboard.setData(ClipboardData(text: link)); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(L10n.of(context).copiedToClipboard)), - ); - }, - child: Container( - padding: const EdgeInsets.symmetric( - vertical: 12.0, - horizontal: 16.0, - ), - decoration: BoxDecoration( - color: theme.colorScheme.secondaryContainer, + child: InkWell( + customBorder: RoundedRectangleBorder( borderRadius: BorderRadius.circular(99), ), - child: Row( - spacing: 16.0, - children: [ - const Icon( - Icons.copy_outlined, - size: 20.0, + onTap: () async { + final String initialUrl = + kIsWeb ? html.window.origin! : Environment.frontendURL; + final link = + "$initialUrl/#/join_with_link?${SpaceConstants.classCode}=${room.classCode(context)}"; + await Clipboard.setData(ClipboardData(text: link)); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(L10n.of(context).copiedToClipboard), ), - Text( - L10n.of(context).copyClassLink, - style: TextStyle( - color: theme.colorScheme.onPrimaryContainer, - fontWeight: FontWeight.normal, - fontSize: 16.0, + ); + }, + child: Container( + padding: const EdgeInsets.symmetric( + vertical: 12.0, + horizontal: 16.0, + ), + decoration: BoxDecoration( + color: theme.colorScheme.secondaryContainer, + borderRadius: BorderRadius.circular(99), + ), + child: Row( + spacing: 16.0, + children: [ + const Icon( + Icons.copy_outlined, + size: 20.0, ), - ), - ], + Text( + L10n.of(context).copyClassLink, + style: TextStyle( + color: theme.colorScheme.onPrimaryContainer, + fontWeight: FontWeight.normal, + fontSize: 16.0, + ), + ), + ], + ), ), ), ), - ), // Pangea# Padding( // #Pangea diff --git a/lib/pages/new_group/new_group.dart b/lib/pages/new_group/new_group.dart index b9d8467d0..b2b86036e 100644 --- a/lib/pages/new_group/new_group.dart +++ b/lib/pages/new_group/new_group.dart @@ -237,7 +237,10 @@ class NewGroupController extends State { room = client.getRoomById(spaceId); } if (room == null) return; - GoogleAnalytics.createClass(room.name, room.classCode(context)); + final spaceCode = room.classCode(context); + if (spaceCode != null) { + GoogleAnalytics.createClass(room.name, spaceCode); + } try { await room.invite(BotName.byEnvironment); } catch (err) { diff --git a/lib/pangea/activity_planner/activity_plan_card.dart b/lib/pangea/activity_planner/activity_plan_card.dart index 0d721e11e..c735f253a 100644 --- a/lib/pangea/activity_planner/activity_plan_card.dart +++ b/lib/pangea/activity_planner/activity_plan_card.dart @@ -174,12 +174,25 @@ class ActivityPlanCardState extends State { } 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); + try { + 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); + } + } catch (e, s) { + ErrorHandler.logError( + e: e, + s: s, + data: { + "imageURL": _imageURL, + }, + ); + if (mounted) { + setState(() => _avatar = null); + } } } diff --git a/lib/pangea/chat_settings/widgets/class_invitation_buttons.dart b/lib/pangea/chat_settings/widgets/class_invitation_buttons.dart index 8f2d6ad03..6e2dac6e5 100644 --- a/lib/pangea/chat_settings/widgets/class_invitation_buttons.dart +++ b/lib/pangea/chat_settings/widgets/class_invitation_buttons.dart @@ -19,6 +19,9 @@ class ClassInvitationButtons extends StatelessWidget { Widget build(BuildContext context) { final Room? room = Matrix.of(context).client.getRoomById(roomId); if (room == null) return Text(L10n.of(context).oopsSomethingWentWrong); + if (room.classCode(context) == null) { + return const SizedBox(); + } final copyClassLinkListTile = ListTile( title: Text( @@ -67,7 +70,7 @@ class ClassInvitationButtons extends StatelessWidget { onTap: () async { //PTODO-Lala: Standarize toast //PTODO - explore using Fluffyshare for this - await Clipboard.setData(ClipboardData(text: room.classCode(context))); + await Clipboard.setData(ClipboardData(text: room.classCode(context)!)); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(L10n.of(context).copiedToClipboard)), ); diff --git a/lib/pangea/choreographer/controllers/choreographer.dart b/lib/pangea/choreographer/controllers/choreographer.dart index da59e978d..1733716d8 100644 --- a/lib/pangea/choreographer/controllers/choreographer.dart +++ b/lib/pangea/choreographer/controllers/choreographer.dart @@ -456,11 +456,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 +463,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, 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..03b034283 100644 --- a/lib/pangea/choreographer/controllers/igc_controller.dart +++ b/lib/pangea/choreographer/controllers/igc_controller.dart @@ -293,6 +293,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/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/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/extensions/room_space_settings_extension.dart b/lib/pangea/extensions/room_space_settings_extension.dart index 3bd94f106..e5cdf1721 100644 --- a/lib/pangea/extensions/room_space_settings_extension.dart +++ b/lib/pangea/extensions/room_space_settings_extension.dart @@ -1,14 +1,14 @@ part of "pangea_room_extension.dart"; extension SpaceRoomExtension on Room { - String classCode(BuildContext context) { + String? classCode(BuildContext context) { if (!isSpace) { for (final Room potentialClassRoom in pangeaSpaceParents) { if (potentialClassRoom.isSpace) { return SpaceRoomExtension(potentialClassRoom).classCode(context); } } - return L10n.of(context).notInClass; + return null; } final roomJoinRules = getState(EventTypes.RoomJoinRules, ""); if (roomJoinRules != null) { @@ -17,7 +17,7 @@ extension SpaceRoomExtension on Room { return accessCode; } } - return L10n.of(context).noClassCode; + return null; } void checkClass() { 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_selection_repo.dart b/lib/pangea/practice_activities/practice_selection_repo.dart index 3e31eb0f6..fc97ed381 100644 --- a/lib/pangea/practice_activities/practice_selection_repo.dart +++ b/lib/pangea/practice_activities/practice_selection_repo.dart @@ -48,7 +48,7 @@ class PracticeSelectionRepo { } static void clean() { - final Iterable keys = _storage.getKeys(); + final keys = _storage.getKeys(); if (keys.length > 300) { final entries = keys .map((key) => _parsePracticeSelection(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/pubspec.yaml b/pubspec.yaml index 894a183f4..9b9574d26 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" From 97876e59183827705a3d5ef937aefa2d8d80ad4a Mon Sep 17 00:00:00 2001 From: ggurdin <46800240+ggurdin@users.noreply.github.com> Date: Thu, 22 May 2025 13:02:57 -0400 Subject: [PATCH 25/26] Merge prod into main (#2867) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: fully update match info after auto-accepting replacement, add more error handling in construct token span * bump version * fix: don't stop activity language on fail to fetch image URL * fix: don't show copy class code buttons into class code is null * fix: use activity type enum name in key instead of string * chore: fully update match info after auto-accepting replacement, add … (#2866) * chore: fully update match info after auto-accepting replacement, add more error handling in construct token span * bump version * fix: don't stop activity language on fail to fetch image URL * fix: don't show copy class code buttons into class code is null * fix: use activity type enum name in key instead of string --- .../activity_type_enum.dart | 19 ------------------- .../message_activity_request.dart | 2 +- .../practice_activity_model.dart | 2 +- .../practice_activities/practice_target.dart | 17 ++++++++++++++--- pubspec.yaml | 2 +- 5 files changed, 17 insertions(+), 25 deletions(-) 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_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/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" From b5b06dea4076aa33f3cfb79ea56abcb6418751cd Mon Sep 17 00:00:00 2001 From: ggurdin Date: Thu, 22 May 2025 13:46:18 -0400 Subject: [PATCH 26/26] chore: formatting --- lib/pages/chat/events/reply_content.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/pages/chat/events/reply_content.dart b/lib/pages/chat/events/reply_content.dart index 24421c9cb..be8d67198 100644 --- a/lib/pages/chat/events/reply_content.dart +++ b/lib/pages/chat/events/reply_content.dart @@ -1,8 +1,9 @@ -import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart'; import 'package:flutter/material.dart'; + import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:matrix/matrix.dart'; +import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart'; import '../../../config/app_config.dart'; class ReplyContent extends StatelessWidget {