From fae97c0ab282b814330912f604d1cda6f307d53c Mon Sep 17 00:00:00 2001 From: ggurdin <46800240+ggurdin@users.noreply.github.com> Date: Thu, 20 Mar 2025 13:40:19 -0400 Subject: [PATCH] refactor: invite page redesign (#2186) --- assets/l10n/intl_en.arb | 6 +- .../invitation_selection.dart | 161 ++++---- .../invitation_selection_view.dart | 361 ++++++++++++++---- .../constants/room_settings_constants.dart | 4 + .../widgets/refer_friends_dialog.dart | 95 +++++ 5 files changed, 487 insertions(+), 140 deletions(-) create mode 100644 lib/pangea/chat_settings/constants/room_settings_constants.dart create mode 100644 lib/pangea/chat_settings/widgets/refer_friends_dialog.dart diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index 3f0019614..f0d0294df 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -4813,5 +4813,9 @@ "pleaseEnterInt": "Please enter a number", "home": "Home", "join": "Join", - "learnByTexting": "Learn by texting" + "learnByTexting": "Learn by texting", + "startChatting": "Start chatting", + "referFriends": "Refer friends", + "referFriendDialogTitle": "Invite a friend to your conversation", + "referFriendDialogDesc": "Do you have a friend who is excited to learn a new language with you? Then copy and send this invitation link to join and start chatting with you today." } diff --git a/lib/pages/invitation_selection/invitation_selection.dart b/lib/pages/invitation_selection/invitation_selection.dart index b7922e9e2..b43096ecb 100644 --- a/lib/pages/invitation_selection/invitation_selection.dart +++ b/lib/pages/invitation_selection/invitation_selection.dart @@ -34,13 +34,82 @@ class InvitationSelectionController extends State { String? get roomId => widget.roomId; + // #Pangea + List? get participants { + final room = Matrix.of(context).client.getRoomById(roomId!); + return room?.getParticipants(); + } + + List get _membershipOrder => [ + Membership.join, + Membership.invite, + Membership.knock, + Membership.leave, + Membership.ban, + ]; + + String? membershipCopy(Membership? membership) => switch (membership) { + Membership.ban => L10n.of(context).banned, + Membership.invite => L10n.of(context).invited, + Membership.join => null, + Membership.knock => L10n.of(context).knocking, + Membership.leave => L10n.of(context).leftTheChat, + null => null, + }; + + int _sortUsers(User a, User b) { + // sort yourself to the top + final client = Matrix.of(context).client; + if (a.id == client.userID) return -1; + if (b.id == client.userID) return 1; + + // sort the bot to the bottom + if (a.id == BotName.byEnvironment) return 1; + if (b.id == BotName.byEnvironment) return -1; + + if (participants != null) { + final participantA = participants!.firstWhereOrNull((u) => u.id == a.id); + final participantB = participants!.firstWhereOrNull((u) => u.id == b.id); + // sort all participants first, with admins first, then moderators, then the rest + if (participantA?.membership == null && + participantB?.membership != null) { + return 1; + } + if (participantA?.membership != null && + participantB?.membership == null) { + return -1; + } + if (participantA?.membership != null && + participantB?.membership != null) { + final aIndex = _membershipOrder.indexOf(participantA!.membership); + final bIndex = _membershipOrder.indexOf(participantB!.membership); + if (aIndex != bIndex) { + return aIndex.compareTo(bIndex); + } + } + } + + // finally, sort by displayname + final aName = a.calcDisplayname().toLowerCase(); + final bName = b.calcDisplayname().toLowerCase(); + return aName.compareTo(bName); + } + // Pangea# + Future> getContacts(BuildContext context) async { final client = Matrix.of(context).client; final room = client.getRoomById(roomId!)!; final participants = (room.summary.mJoinedMemberCount ?? 0) > 100 ? room.getParticipants() - : await room.requestParticipants(); + // #Pangea + // : await room.requestParticipants(); + : await room.requestParticipants( + [Membership.join, Membership.invite, Membership.knock], + false, + true, + ); + // Pangea# participants.removeWhere( (u) => ![Membership.join, Membership.invite].contains(u.membership), ); @@ -53,16 +122,26 @@ class InvitationSelectionController extends State { .getParticipants() .firstWhereOrNull((u) => u.id != client.userID), ) + .where((u) => u != null) + .cast() // Pangea# .toList(); // #Pangea - contacts.removeWhere((u) => u == null || u.id != BotName.byEnvironment); - contacts.sort( - (a, b) => a!.calcDisplayname().toLowerCase().compareTo( - b!.calcDisplayname().toLowerCase(), - ), - ); - return contacts.cast(); + final mutuals = client.rooms + .where((r) => r.isSpace) + .map((r) => r.getParticipants()) + .expand((element) => element) + .toList(); + + for (final user in mutuals) { + final index = contacts.indexWhere((u) => u.id == user.id); + if (index == -1) { + contacts.add(user); + } + } + + contacts.sort(_sortUsers); + return contacts; // contacts.sort( // (a, b) => a.calcDisplayname().toLowerCase().compareTo( // b.calcDisplayname().toLowerCase(), @@ -72,72 +151,6 @@ class InvitationSelectionController extends State { //Pangea# } - //#Pangea - // // add all students (already local) from spaceParents who aren't already in room to eligibleStudents - // // use room.members to get all users in room - // bool _initialized = false; - // Future> eligibleStudents( - // BuildContext context, - // String text, - // ) async { - // if (!_initialized) { - // _initialized = true; - // await requestParentSpaceParticipants(); - // } - - // final eligibleStudents = []; - // final spaceParents = room?.pangeaSpaceParents; - // if (spaceParents == null) return eligibleStudents; - - // final userId = Matrix.of(context).client.userID; - // for (final Room space in spaceParents) { - // eligibleStudents.addAll( - // space.getParticipants().where( - // (spaceUser) => - // spaceUser.id != BotName.byEnvironment && - // spaceUser.id != "@support:staging.pangea.chat" && - // spaceUser.id != userId && - // (text.isEmpty || - // (spaceUser.displayName - // ?.toLowerCase() - // .contains(text.toLowerCase()) ?? - // false) || - // spaceUser.id.toLowerCase().contains(text.toLowerCase())), - // ), - // ); - // } - // return eligibleStudents; - // } - - // Future - // eligibleStudentsAsSearchUserDirectoryResponse( - // BuildContext context, - // String text, - // ) async { - // return SearchUserDirectoryResponse( - // results: (await eligibleStudents(context, text)) - // .map( - // (e) => Profile( - // userId: e.id, - // avatarUrl: e.avatarUrl, - // displayName: e.displayName, - // ), - // ) - // .toList(), - // limited: false, - // ); - // } - - // List studentsInRoom(BuildContext context) => - // room - // ?.getParticipants() - // .where( - // (u) => [Membership.join, Membership.invite].contains(u.membership), - // ) - // .toList() ?? - // []; - //Pangea# - void inviteAction(BuildContext context, String id, String displayname) async { final room = Matrix.of(context).client.getRoomById(roomId!)!; diff --git a/lib/pages/invitation_selection/invitation_selection_view.dart b/lib/pages/invitation_selection/invitation_selection_view.dart index d076ceb0f..ca0d14237 100644 --- a/lib/pages/invitation_selection/invitation_selection_view.dart +++ b/lib/pages/invitation_selection/invitation_selection_view.dart @@ -1,10 +1,19 @@ 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:matrix/matrix.dart'; +import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/pages/invitation_selection/invitation_selection.dart'; import 'package:fluffychat/pages/user_bottom_sheet/user_bottom_sheet.dart'; +import 'package:fluffychat/pangea/analytics_misc/level_display_name.dart'; +import 'package:fluffychat/pangea/chat_settings/constants/room_settings_constants.dart'; +import 'package:fluffychat/pangea/chat_settings/widgets/refer_friends_dialog.dart'; +import 'package:fluffychat/pangea/common/widgets/full_width_dialog.dart'; import 'package:fluffychat/utils/adaptive_bottom_sheet.dart'; import 'package:fluffychat/widgets/avatar.dart'; import 'package:fluffychat/widgets/layouts/max_width_body.dart'; @@ -42,10 +51,20 @@ class InvitationSelectionView extends StatelessWidget { ), body: MaxWidthBody( innerPadding: const EdgeInsets.symmetric(vertical: 8), + // #Pangea + withScrolling: false, + // Pangea# child: Column( children: [ Padding( - padding: const EdgeInsets.all(16.0), + // #Pangea + // padding: const EdgeInsets.all(16.0), + padding: const EdgeInsets.only( + bottom: 16.0, + left: 16.0, + right: 16.0, + ), + // Pangea# child: TextField( textInputAction: TextInputAction.search, decoration: InputDecoration( @@ -81,70 +100,200 @@ class InvitationSelectionView extends StatelessWidget { onChanged: controller.searchUserWithCoolDown, ), ), - StreamBuilder( - stream: room.client.onRoomState.stream - .where((update) => update.roomId == room.id), - builder: (context, snapshot) { - final participants = - room.getParticipants().map((user) => user.id).toSet(); - return controller.foundProfiles.isNotEmpty - ? ListView.builder( - physics: const NeverScrollableScrollPhysics(), - shrinkWrap: true, - itemCount: controller.foundProfiles.length, - itemBuilder: (BuildContext context, int i) => - _InviteContactListTile( - profile: controller.foundProfiles[i], - isMember: participants - .contains(controller.foundProfiles[i].userId), - onTap: () => controller.inviteAction( - context, - controller.foundProfiles[i].userId, - controller.foundProfiles[i].displayName ?? - controller.foundProfiles[i].userId.localpart ?? - L10n.of(context).user, + // #Pangea + // StreamBuilder( + Expanded( + child: StreamBuilder( + // Pangea# + stream: room.client.onRoomState.stream + .where((update) => update.roomId == room.id), + builder: (context, snapshot) { + final participants = + room.getParticipants().map((user) => user.id).toSet(); + return controller.foundProfiles.isNotEmpty + ? ListView.builder( + // #Pangea + // physics: const NeverScrollableScrollPhysics(), + // shrinkWrap: true, + // Pangea# + itemCount: controller.foundProfiles.length, + itemBuilder: (BuildContext context, int i) => + _InviteContactListTile( + profile: controller.foundProfiles[i], + isMember: participants + .contains(controller.foundProfiles[i].userId), + onTap: () => controller.inviteAction( + context, + controller.foundProfiles[i].userId, + controller.foundProfiles[i].displayName ?? + controller + .foundProfiles[i].userId.localpart ?? + L10n.of(context).user, + ), + ), + ) + : FutureBuilder>( + future: controller.getContacts(context), + builder: (BuildContext context, snapshot) { + if (!snapshot.hasData) { + return const Center( + child: CircularProgressIndicator.adaptive( + strokeWidth: 2, + ), + ); + } + final contacts = snapshot.data!; + return ListView.builder( + // #Pangea + // physics: const NeverScrollableScrollPhysics(), + // shrinkWrap: true, + // itemCount: contacts.length, + // itemBuilder: (BuildContext context, int i) => + // _InviteContactListTile( + itemCount: contacts.length + 1, + itemBuilder: (BuildContext context, int i) { + if (i == contacts.length) { + return room.isSpace + ? const SizedBox() + : Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: SizedBox( + width: 450, + child: CachedNetworkImage( + imageUrl: + "${AppConfig.assetsBaseURL}/${RoomSettingsConstants.referFriendAsset}", + errorWidget: + (context, url, error) => + const SizedBox(), + placeholder: (context, url) => + const Center( + child: + CircularProgressIndicator + .adaptive(), + ), + ), + ), + ), + ); + } + return _InviteContactListTile( + // Pangea# + user: contacts[i], + profile: Profile( + avatarUrl: contacts[i].avatarUrl, + displayName: contacts[i].displayName ?? + contacts[i].id.localpart ?? + L10n.of(context).user, + userId: contacts[i].id, + ), + isMember: + participants.contains(contacts[i].id), + onTap: () => controller.inviteAction( + context, + contacts[i].id, + contacts[i].displayName ?? + contacts[i].id.localpart ?? + L10n.of(context).user, + ), + // #Pangea + roomPowerLevel: controller.participants + ?.firstWhereOrNull( + (element) => + element.id == contacts[i].id, + ) + ?.powerLevel, + membership: controller.participants + ?.firstWhereOrNull( + (element) => + element.id == contacts[i].id, + ) + ?.membership, + // Pangea# + ); + }, + ); + }, + ); + }, + ), + ), + // #Pangea + if (!room.isSpace) + Padding( + padding: EdgeInsets.only( + left: 8.0, + right: 8.0, + top: 16.0, + bottom: FluffyThemes.isColumnMode(context) ? 0 : 16.0, + ), + child: Row( + spacing: 8.0, + children: [ + Expanded( + child: ElevatedButton( + onPressed: () => showDialog( + context: context, + builder: (context) => FullWidthDialog( + dialogContent: ReferFriendsDialog(room: room), + maxWidth: 600.0, + maxHeight: 800.0, ), ), - ) - : FutureBuilder>( - future: controller.getContacts(context), - builder: (BuildContext context, snapshot) { - if (!snapshot.hasData) { - return const Center( - child: CircularProgressIndicator.adaptive( - strokeWidth: 2, - ), - ); - } - final contacts = snapshot.data!; - return ListView.builder( - physics: const NeverScrollableScrollPhysics(), - shrinkWrap: true, - itemCount: contacts.length, - itemBuilder: (BuildContext context, int i) => - _InviteContactListTile( - user: contacts[i], - profile: Profile( - avatarUrl: contacts[i].avatarUrl, - displayName: contacts[i].displayName ?? - contacts[i].id.localpart ?? - L10n.of(context).user, - userId: contacts[i].id, - ), - isMember: participants.contains(contacts[i].id), - onTap: () => controller.inviteAction( - context, - contacts[i].id, - contacts[i].displayName ?? - contacts[i].id.localpart ?? - L10n.of(context).user, + style: ElevatedButton.styleFrom( + backgroundColor: AppConfig.gold, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + spacing: 12.0, + children: [ + Icon( + Icons.redeem_outlined, + color: Theme.of(context).brightness == + Brightness.light + ? DefaultTextStyle.of(context).style.color + : Theme.of(context).colorScheme.surface, + ), + Text( + L10n.of(context).referFriends, + style: TextStyle( + color: Theme.of(context).brightness == + Brightness.light + ? null + : Theme.of(context).colorScheme.surface, + fontWeight: FontWeight.bold, ), ), - ); - }, - ); - }, - ), + ], + ), + ), + ), + Expanded( + child: ElevatedButton( + onPressed: () => context.go("/rooms/${room.id}"), + style: ElevatedButton.styleFrom(), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + spacing: 12.0, + children: [ + Icon( + Icons.chat_outlined, + color: DefaultTextStyle.of(context).style.color, + ), + Text( + L10n.of(context).startChatting, + style: const TextStyle( + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ), + ], + ), + ), + // Pangea# ], ), ), @@ -157,16 +306,47 @@ class _InviteContactListTile extends StatelessWidget { final User? user; final bool isMember; final void Function() onTap; + // #Pangea + final int? roomPowerLevel; + final Membership? membership; + // Pangea# const _InviteContactListTile({ required this.profile, this.user, required this.isMember, required this.onTap, + // #Pangea + this.roomPowerLevel, + this.membership, + // Pangea# }); @override Widget build(BuildContext context) { + // #Pangea + String? permissionCopy() { + if (roomPowerLevel == null) { + return null; + } + + return roomPowerLevel! >= 100 + ? L10n.of(context).admin + : roomPowerLevel! >= 50 + ? L10n.of(context).moderator + : null; + } + + String? membershipCopy() => switch (membership) { + Membership.ban => L10n.of(context).banned, + Membership.invite => L10n.of(context).invited, + Membership.join => null, + Membership.knock => L10n.of(context).knocking, + Membership.leave => L10n.of(context).leftTheChat, + null => null, + }; + // Pangea# + final theme = Theme.of(context); final l10n = L10n.of(context); @@ -197,11 +377,62 @@ class _InviteContactListTile extends StatelessWidget { color: theme.colorScheme.secondary, ), ), - trailing: TextButton.icon( - onPressed: isMember ? null : onTap, - label: Text(isMember ? l10n.participant : l10n.invite), - icon: Icon(isMember ? Icons.check : Icons.add), + // #Pangea + // trailing: TextButton.icon( + // onPressed: isMember ? null : onTap, + // label: Text(isMember ? l10n.participant : l10n.invite), + // icon: Icon(isMember ? Icons.check : Icons.add), + // ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + LevelDisplayName(userId: profile.userId), + if (membershipCopy() != null) + Container( + padding: const EdgeInsets.all(4), + margin: const EdgeInsets.symmetric(horizontal: 8), + decoration: BoxDecoration( + color: theme.secondaryHeaderColor, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + membershipCopy()!, + style: theme.textTheme.labelSmall, + ), + ) + else if (permissionCopy() != null) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + margin: const EdgeInsets.only(right: 8), + decoration: BoxDecoration( + color: roomPowerLevel! >= 100 + ? theme.colorScheme.tertiary + : theme.colorScheme.tertiaryContainer, + borderRadius: BorderRadius.circular( + AppConfig.borderRadius, + ), + ), + child: Text( + permissionCopy()!, + style: theme.textTheme.labelSmall?.copyWith( + color: roomPowerLevel! >= 100 + ? theme.colorScheme.onTertiary + : theme.colorScheme.onTertiaryContainer, + ), + ), + ) + else if (!isMember || roomPowerLevel == null || roomPowerLevel! < 50) + TextButton.icon( + onPressed: isMember ? null : onTap, + label: Text(isMember ? l10n.participant : l10n.invite), + icon: Icon(isMember ? Icons.check : Icons.add), + ), + ], ), + // Pangea# ); } } diff --git a/lib/pangea/chat_settings/constants/room_settings_constants.dart b/lib/pangea/chat_settings/constants/room_settings_constants.dart new file mode 100644 index 000000000..82cc56ea5 --- /dev/null +++ b/lib/pangea/chat_settings/constants/room_settings_constants.dart @@ -0,0 +1,4 @@ +class RoomSettingsConstants { + static const String referFriendAsset = "Refer+a+friend.png"; + static const String referFriendDialogAsset = "Refer+a+friend_2.png"; +} diff --git a/lib/pangea/chat_settings/widgets/refer_friends_dialog.dart b/lib/pangea/chat_settings/widgets/refer_friends_dialog.dart new file mode 100644 index 000000000..4ea155098 --- /dev/null +++ b/lib/pangea/chat_settings/widgets/refer_friends_dialog.dart @@ -0,0 +1,95 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/pangea/chat_settings/constants/room_settings_constants.dart'; +import 'package:fluffychat/pangea/common/config/environment.dart'; + +class ReferFriendsDialog extends StatelessWidget { + final Room room; + + const ReferFriendsDialog({ + required this.room, + super.key, + }); + + @override + Widget build(BuildContext context) { + final inviteLink = + "${Environment.frontendURL}/#/join_with_alias?alias=${Uri.encodeComponent(room.id)}"; + return Container( + padding: const EdgeInsets.all(24.0), + decoration: BoxDecoration( + color: Theme.of(context).scaffoldBackgroundColor, + borderRadius: BorderRadius.circular(20.0), + border: Border.all( + color: Theme.of(context).colorScheme.primary.withAlpha(50), + width: 0.5, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 16.0, + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.of(context).pop(), + ), + Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: SizedBox( + width: 450, + child: CachedNetworkImage( + imageUrl: + "${AppConfig.assetsBaseURL}/${RoomSettingsConstants.referFriendDialogAsset}", + errorWidget: (context, url, error) => const SizedBox(), + placeholder: (context, url) => const Center( + child: CircularProgressIndicator.adaptive(), + ), + ), + ), + ), + ), + Text( + L10n.of(context).referFriendDialogTitle, + style: Theme.of(context).textTheme.displaySmall?.copyWith( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.primary, + ), + ), + Text( + L10n.of(context).referFriendDialogDesc, + style: Theme.of(context).textTheme.titleMedium, + ), + Material( + color: Colors.transparent, // Keeps the `Container`'s background + child: ListTile( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24.0), + side: BorderSide( + color: Theme.of(context).colorScheme.primary, + ), + ), + title: Text( + inviteLink, + overflow: TextOverflow.ellipsis, + ), + trailing: const Icon(Icons.copy_outlined), + onTap: () async { + Clipboard.setData( + ClipboardData(text: inviteLink), + ); + }, + ), + ), + ], + ), + ); + } +}