diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index ede10af3..a57e3e9e 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -2104,7 +2104,7 @@ "type": "text", "placeholders": {} }, - "separateChatTypes": "Separate Direct Chats, Groups, and Spaces", + "separateChatTypes": "Separate Direct Chats and Groups", "@separateChatTypes": { "type": "text", "placeholders": {} @@ -2892,5 +2892,10 @@ "user": "User", "custom": "Custom", "whyIsThisMessageEncrypted": "Why is this message unreadable?", - "noKeyForThisMessage": "This can happen if the message was sent before you have signed in to your account at this device.\n\nIt is also possible that the sender has blocked your device or something went wrong with the internet connection.\n\nAre you able to read the message on another session? Then you can transfer the message from it! Go to Settings > Devices and make sure that your devices have verified each other. When you open the room the next time and both sessions are in the foreground, the keys will be transmitted automatically.\n\nDo you not want to loose the keys when logging out or switching devices? Make sure that you have enabled the chat backup in the settings." + "noKeyForThisMessage": "This can happen if the message was sent before you have signed in to your account at this device.\n\nIt is also possible that the sender has blocked your device or something went wrong with the internet connection.\n\nAre you able to read the message on another session? Then you can transfer the message from it! Go to Settings > Devices and make sure that your devices have verified each other. When you open the room the next time and both sessions are in the foreground, the keys will be transmitted automatically.\n\nDo you not want to loose the keys when logging out or switching devices? Make sure that you have enabled the chat backup in the settings.", + "newGroup": "New group", + "newSpace": "New space", + "enterSpace": "Enter space", + "enterRoom": "Enter room", + "allSpaces": "All spaces" } diff --git a/lib/config/themes.dart b/lib/config/themes.dart index 5fa138f8..a1f01793 100644 --- a/lib/config/themes.dart +++ b/lib/config/themes.dart @@ -1,13 +1,24 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:vrouter/vrouter.dart'; + import 'package:fluffychat/utils/platform_infos.dart'; +import '../widgets/matrix.dart'; import 'app_config.dart'; abstract class FluffyThemes { static const double columnWidth = 360.0; + + static bool isColumnModeByWidth(double width) => width > columnWidth * 2 + 64; + static bool isColumnMode(BuildContext context) => - MediaQuery.of(context).size.width > columnWidth * 2; + isColumnModeByWidth(MediaQuery.of(context).size.width); + + static bool getDisplayNavigationRail(BuildContext context) => + !VRouter.of(context).path.startsWith('/settings') && + (Matrix.of(context).client.rooms.any((room) => room.isSpace) || + AppConfig.separateChatTypes); static const fallbackTextStyle = TextStyle( fontFamily: 'Roboto', diff --git a/lib/pages/chat_list/chat_list.dart b/lib/pages/chat_list/chat_list.dart index 0b9e082f..0ceaebab 100644 --- a/lib/pages/chat_list/chat_list.dart +++ b/lib/pages/chat_list/chat_list.dart @@ -13,12 +13,12 @@ import 'package:uni_links/uni_links.dart'; import 'package:vrouter/vrouter.dart'; import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/pages/chat_list/chat_list_view.dart'; -import 'package:fluffychat/pages/chat_list/spaces_entry.dart'; import 'package:fluffychat/utils/famedlysdk_store.dart'; import 'package:fluffychat/utils/localized_exception_extension.dart'; +import 'package:fluffychat/utils/matrix_sdk_extensions.dart/client_stories_extension.dart'; import 'package:fluffychat/utils/platform_infos.dart'; -import 'package:fluffychat/utils/space_navigator.dart'; import '../../../utils/account_bundles.dart'; import '../../utils/matrix_sdk_extensions.dart/matrix_file_extension.dart'; import '../../utils/url_launcher.dart'; @@ -30,7 +30,11 @@ import '../settings_account/settings_account.dart'; import 'package:fluffychat/utils/tor_stub.dart' if (dart.library.html) 'package:tor_detector_web/tor_detector_web.dart'; -enum SelectMode { normal, share, select } +enum SelectMode { + normal, + share, + select, +} enum PopupMenuAction { settings, @@ -41,6 +45,13 @@ enum PopupMenuAction { archive, } +enum ActiveFilter { + allChats, + groups, + messages, + spaces, +} + class ChatList extends StatefulWidget { const ChatList({Key? key}) : super(key: key); @@ -56,7 +67,95 @@ class ChatListController extends State StreamSubscription? _intentUriStreamSubscription; - SpacesEntry? _activeSpacesEntry; + bool get displayNavigationBar => + !FluffyThemes.isColumnMode(context) && + (spaces.isNotEmpty || AppConfig.separateChatTypes); + + String? activeSpaceId; + + void resetActiveSpaceId() { + setState(() { + activeSpaceId = null; + }); + } + + void setActiveSpace(String? spaceId) { + setState(() { + activeSpaceId = spaceId; + activeFilter = ActiveFilter.spaces; + }); + } + + int get selectedIndex { + switch (activeFilter) { + case ActiveFilter.allChats: + return 0; + case ActiveFilter.groups: + return 0; + case ActiveFilter.messages: + return 1; + case ActiveFilter.spaces: + return AppConfig.separateChatTypes ? 2 : 1; + } + } + + void onDestinationSelected(int? i) { + switch (i) { + case 0: + if (AppConfig.separateChatTypes) { + setState(() { + activeFilter = ActiveFilter.groups; + }); + } else { + setState(() { + activeFilter = ActiveFilter.allChats; + }); + } + break; + case 1: + if (AppConfig.separateChatTypes) { + setState(() { + activeFilter = ActiveFilter.messages; + }); + } else { + setState(() { + activeFilter = ActiveFilter.spaces; + }); + } + break; + case 2: + setState(() { + activeFilter = ActiveFilter.spaces; + }); + break; + } + } + + ActiveFilter activeFilter = AppConfig.separateChatTypes + ? ActiveFilter.messages + : ActiveFilter.allChats; + + List get filteredRooms { + final rooms = Matrix.of(context).client.rooms; + switch (activeFilter) { + case ActiveFilter.allChats: + return rooms + .where((room) => !room.isSpace && !room.isStoryRoom) + .toList(); + case ActiveFilter.groups: + return rooms + .where((room) => + !room.isSpace && !room.isDirectChat && !room.isStoryRoom) + .toList(); + case ActiveFilter.messages: + return rooms + .where((room) => + !room.isSpace && room.isDirectChat && !room.isStoryRoom) + .toList(); + case ActiveFilter.spaces: + return rooms.where((room) => room.isSpace).toList(); + } + } bool isSearchMode = false; Future? publicRoomsResponse; @@ -154,15 +253,8 @@ class ChatListController extends State bool isTorBrowser = false; - SpacesEntry get activeSpacesEntry { - final id = _activeSpacesEntry; - return (id == null || !id.stillValid(context)) ? defaultSpacesEntry : id; - } - BoxConstraints? snappingSheetContainerSize; - String? get activeSpaceId => activeSpacesEntry.getSpace(context)?.id; - final ScrollController scrollController = ScrollController(); bool scrolledToTop = true; @@ -190,26 +282,6 @@ class ChatListController extends State List get spaces => Matrix.of(context).client.rooms.where((r) => r.isSpace).toList(); - // Note that this could change due to configuration, etc. - // Also be aware that _activeSpacesEntry = null is the expected reset method. - SpacesEntry get defaultSpacesEntry => AppConfig.separateChatTypes - ? DirectChatsSpacesEntry() - : AllRoomsSpacesEntry(); - - List get spacesEntries { - if (AppConfig.separateChatTypes) { - return [ - defaultSpacesEntry, - GroupsSpacesEntry(), - ...spaces.map((space) => SpaceSpacesEntry(space)).toList() - ]; - } - return [ - defaultSpacesEntry, - ...spaces.map((space) => SpaceSpacesEntry(space)).toList() - ]; - } - final selectedRoomIds = {}; String? get activeChat => VRouter.of(context).pathParameters['roomid']; @@ -296,8 +368,6 @@ class ChatListController extends State _checkTorBrowser(); - _subscribeSpaceChanges(); - super.initState(); } @@ -419,73 +489,43 @@ class ChatListController extends State } } - Future addOrRemoveToSpace() async { - final id = activeSpaceId; - if (id != null) { - final consent = await showOkCancelAlertDialog( + Future addToSpace() async { + final selectedSpace = await showConfirmationDialog( context: context, - title: L10n.of(context)!.removeFromSpace, - message: L10n.of(context)!.removeFromSpaceDescription, - okLabel: L10n.of(context)!.remove, - cancelLabel: L10n.of(context)!.cancel, - isDestructiveAction: true, + title: L10n.of(context)!.addToSpace, + message: L10n.of(context)!.addToSpaceDescription, fullyCapitalizedForMaterial: false, - ); - if (consent != OkCancelResult.ok) return; - - final space = Matrix.of(context).client.getRoomById(id); - final result = await showFutureLoadingDialog( - context: context, - future: () async { + actions: Matrix.of(context) + .client + .rooms + .where((r) => r.isSpace) + .map( + (space) => AlertDialogAction( + key: space.id, + label: space.displayname, + ), + ) + .toList()); + if (selectedSpace == null) return; + final result = await showFutureLoadingDialog( + context: context, + future: () async { + final space = Matrix.of(context).client.getRoomById(selectedSpace)!; + if (space.canSendDefaultStates) { for (final roomId in selectedRoomIds) { - await space!.removeSpaceChild(roomId); + await space.setSpaceChild(roomId); } - }, + } + }, + ); + if (result.error == null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(L10n.of(context)!.chatHasBeenAddedToThisSpace), + ), ); - if (result.error == null) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(L10n.of(context)!.chatHasBeenRemovedFromThisSpace), - ), - ); - } - } else { - final selectedSpace = await showConfirmationDialog( - context: context, - title: L10n.of(context)!.addToSpace, - message: L10n.of(context)!.addToSpaceDescription, - fullyCapitalizedForMaterial: false, - actions: Matrix.of(context) - .client - .rooms - .where((r) => r.isSpace) - .map( - (space) => AlertDialogAction( - key: space.id, - label: space.displayname, - ), - ) - .toList()); - if (selectedSpace == null) return; - final result = await showFutureLoadingDialog( - context: context, - future: () async { - final space = Matrix.of(context).client.getRoomById(selectedSpace)!; - if (space.canSendDefaultStates) { - for (final roomId in selectedRoomIds) { - await space.setSpaceChild(roomId); - } - } - }, - ); - if (result.error == null) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(L10n.of(context)!.chatHasBeenAddedToThisSpace), - ), - ); - } } + setState(() => selectedRoomIds.clear()); } @@ -517,18 +557,6 @@ class ChatListController extends State } } } - - // Load space members to display DM rooms - final spaceId = activeSpaceId; - if (spaceId != null) { - final space = client.getRoomById(spaceId)!; - final localMembers = space.getParticipants().length; - final actualMembersCount = (space.summary.mInvitedMemberCount ?? 0) + - (space.summary.mJoinedMemberCount ?? 0); - if (localMembers < actualMembersCount) { - await space.requestParticipants(); - } - } setState(() { waitForFirstSync = true; }); @@ -546,7 +574,6 @@ class ChatListController extends State void setActiveClient(Client client) { VRouter.of(context).to('/rooms'); setState(() { - _activeSpacesEntry = null; selectedRoomIds.clear(); Matrix.of(context).setActiveClient(client); }); @@ -556,7 +583,6 @@ class ChatListController extends State void setActiveBundle(String bundle) { VRouter.of(context).to('/rooms'); setState(() { - _activeSpacesEntry = null; selectedRoomIds.clear(); Matrix.of(context).activeBundle = bundle; if (!Matrix.of(context) @@ -651,27 +677,6 @@ class ChatListController extends State Future dehydrate() => SettingsAccountController.dehydrateDevice(context); - - _adjustSpaceQuery(String? spaceId) { - cancelSearch(); - setState(() { - if (spaceId != null) { - final matching = - spacesEntries.where((element) => element.routeHandle == spaceId); - if (matching.isNotEmpty) { - _activeSpacesEntry = matching.first; - } else { - _activeSpacesEntry = defaultSpacesEntry; - } - } else { - _activeSpacesEntry = defaultSpacesEntry; - } - }); - } - - void _subscribeSpaceChanges() { - _spacesSubscription = SpaceNavigator.stream.listen(_adjustSpaceQuery); - } } enum EditBundleAction { addToBundle, removeFromBundle } diff --git a/lib/pages/chat_list/chat_list_body.dart b/lib/pages/chat_list/chat_list_body.dart index 26fa86db..4ea30d01 100644 --- a/lib/pages/chat_list/chat_list_body.dart +++ b/lib/pages/chat_list/chat_list_body.dart @@ -5,20 +5,18 @@ import 'package:flutter/material.dart'; import 'package:animations/animations.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:matrix/matrix.dart'; -import 'package:matrix_link_text/link_text.dart'; import 'package:fluffychat/pages/chat_list/chat_list.dart'; import 'package:fluffychat/pages/chat_list/chat_list_item.dart'; import 'package:fluffychat/pages/chat_list/search_title.dart'; -import 'package:fluffychat/pages/chat_list/spaces_entry.dart'; +import 'package:fluffychat/pages/chat_list/space_view.dart'; import 'package:fluffychat/pages/chat_list/stories_header.dart'; -import 'package:fluffychat/utils/url_launcher.dart'; import 'package:fluffychat/widgets/avatar.dart'; import 'package:fluffychat/widgets/profile_bottom_sheet.dart'; import 'package:fluffychat/widgets/public_room_bottom_sheet.dart'; import '../../utils/stream_extension.dart'; +import '../../widgets/connection_status_header.dart'; import '../../widgets/matrix.dart'; -import 'spaces_hierarchy_proposal.dart'; class ChatListViewBody extends StatefulWidget { final ChatListController controller; @@ -33,10 +31,6 @@ class _ChatListViewBodyState extends State { // the matrix sync stream late StreamSubscription _subscription; - // used to check the animation direction - String? _lastUserId; - SpacesEntry? _lastSpace; - @override void initState() { _subscription = Matrix.of(context) @@ -51,160 +45,151 @@ class _ChatListViewBodyState extends State { @override Widget build(BuildContext context) { - final reversed = !_animationReversed(); final roomSearchResult = widget.controller.roomSearchResult; final userSearchResult = widget.controller.userSearchResult; Widget child; if (widget.controller.waitForFirstSync && - Matrix.of(context).client.prevBatch != null) { - final rooms = widget.controller.activeSpacesEntry.getRooms(context); + Matrix.of(context).client.prevBatch != null && + widget.controller.activeFilter != ActiveFilter.spaces) { + final rooms = widget.controller.filteredRooms; - final displayStoriesHeader = widget.controller.activeSpacesEntry - .shouldShowStoriesHeader(context) || - rooms.isEmpty; + final displayStoriesHeader = { + ActiveFilter.allChats, + ActiveFilter.messages, + }.contains(widget.controller.activeFilter); child = ListView.builder( key: ValueKey(Matrix.of(context).client.userID.toString() + - widget.controller.activeSpaceId.toString() + - widget.controller.activeSpacesEntry.runtimeType.toString()), + widget.controller.activeFilter.toString()), controller: widget.controller.scrollController, // add +1 space below in order to properly scroll below the spaces bar - itemCount: rooms.length + (displayStoriesHeader ? 2 : 1), + itemCount: rooms.length + 1, itemBuilder: (BuildContext context, int i) { - if (displayStoriesHeader) { - if (i == 0) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - SpaceRoomListTopBar(widget.controller), - if (roomSearchResult != null) ...[ - SearchTitle( - title: L10n.of(context)!.publicRooms, - icon: const Icon(Icons.explore_outlined), - ), - AnimatedContainer( - height: roomSearchResult.chunk.isEmpty ? 0 : 106, - duration: const Duration(milliseconds: 250), - clipBehavior: Clip.hardEdge, - decoration: const BoxDecoration(), - child: ListView.builder( - scrollDirection: Axis.horizontal, - itemCount: roomSearchResult.chunk.length, - itemBuilder: (context, i) => _SearchItem( - title: roomSearchResult.chunk[i].name ?? - roomSearchResult - .chunk[i].canonicalAlias?.localpart ?? - L10n.of(context)!.group, - avatar: roomSearchResult.chunk[i].avatarUrl, - onPressed: () => showModalBottomSheet( - context: context, - builder: (c) => PublicRoomBottomSheet( - roomAlias: - roomSearchResult.chunk[i].canonicalAlias ?? - roomSearchResult.chunk[i].roomId, - outerContext: context, - chunk: roomSearchResult.chunk[i], - ), + if (i == 0) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (roomSearchResult != null) ...[ + SearchTitle( + title: L10n.of(context)!.publicRooms, + icon: const Icon(Icons.explore_outlined), + ), + AnimatedContainer( + height: roomSearchResult.chunk.isEmpty ? 0 : 106, + duration: const Duration(milliseconds: 250), + clipBehavior: Clip.hardEdge, + decoration: const BoxDecoration(), + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: roomSearchResult.chunk.length, + itemBuilder: (context, i) => _SearchItem( + title: roomSearchResult.chunk[i].name ?? + roomSearchResult + .chunk[i].canonicalAlias?.localpart ?? + L10n.of(context)!.group, + avatar: roomSearchResult.chunk[i].avatarUrl, + onPressed: () => showModalBottomSheet( + context: context, + builder: (c) => PublicRoomBottomSheet( + roomAlias: + roomSearchResult.chunk[i].canonicalAlias ?? + roomSearchResult.chunk[i].roomId, + outerContext: context, + chunk: roomSearchResult.chunk[i], ), ), ), ), - ], - if (userSearchResult != null) ...[ - SearchTitle( - title: L10n.of(context)!.users, - icon: const Icon(Icons.group_outlined), - ), - AnimatedContainer( - height: userSearchResult.results.isEmpty ? 0 : 106, - duration: const Duration(milliseconds: 250), - clipBehavior: Clip.hardEdge, - decoration: const BoxDecoration(), - child: ListView.builder( - scrollDirection: Axis.horizontal, - itemCount: userSearchResult.results.length, - itemBuilder: (context, i) => _SearchItem( - title: userSearchResult.results[i].displayName ?? - userSearchResult.results[i].userId.localpart ?? - L10n.of(context)!.unknownDevice, - avatar: userSearchResult.results[i].avatarUrl, - onPressed: () => showModalBottomSheet( - context: context, - builder: (c) => ProfileBottomSheet( - userId: userSearchResult.results[i].userId, - outerContext: context, - ), + ), + ], + if (userSearchResult != null) ...[ + SearchTitle( + title: L10n.of(context)!.users, + icon: const Icon(Icons.group_outlined), + ), + AnimatedContainer( + height: userSearchResult.results.isEmpty ? 0 : 106, + duration: const Duration(milliseconds: 250), + clipBehavior: Clip.hardEdge, + decoration: const BoxDecoration(), + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: userSearchResult.results.length, + itemBuilder: (context, i) => _SearchItem( + title: userSearchResult.results[i].displayName ?? + userSearchResult.results[i].userId.localpart ?? + L10n.of(context)!.unknownDevice, + avatar: userSearchResult.results[i].avatarUrl, + onPressed: () => showModalBottomSheet( + context: context, + builder: (c) => ProfileBottomSheet( + userId: userSearchResult.results[i].userId, + outerContext: context, ), ), ), ), - ], - if (widget.controller.isSearchMode) - SearchTitle( - title: L10n.of(context)!.stories, - icon: const Icon(Icons.camera_alt_outlined), - ), + ), + ], + if (widget.controller.isSearchMode) + SearchTitle( + title: L10n.of(context)!.stories, + icon: const Icon(Icons.camera_alt_outlined), + ), + if (displayStoriesHeader) StoriesHeader( filter: widget.controller.searchController.text, ), - AnimatedContainer( - height: widget.controller.isTorBrowser ? 64 : 0, - duration: const Duration(milliseconds: 300), - clipBehavior: Clip.hardEdge, - curve: Curves.bounceInOut, - decoration: const BoxDecoration(), - child: Material( - color: Theme.of(context).colorScheme.surface, - child: ListTile( - leading: const Icon(Icons.vpn_key), - title: Text(L10n.of(context)!.dehydrateTor), - subtitle: Text(L10n.of(context)!.dehydrateTorLong), - trailing: const Icon(Icons.chevron_right_outlined), - onTap: widget.controller.dehydrate, - ), + const ConnectionStatusHeader(), + AnimatedContainer( + height: widget.controller.isTorBrowser ? 64 : 0, + duration: const Duration(milliseconds: 300), + clipBehavior: Clip.hardEdge, + curve: Curves.bounceInOut, + decoration: const BoxDecoration(), + child: Material( + color: Theme.of(context).colorScheme.surface, + child: ListTile( + leading: const Icon(Icons.vpn_key), + title: Text(L10n.of(context)!.dehydrateTor), + subtitle: Text(L10n.of(context)!.dehydrateTorLong), + trailing: const Icon(Icons.chevron_right_outlined), + onTap: widget.controller.dehydrate, ), ), - if (widget.controller.isSearchMode) - SearchTitle( - title: L10n.of(context)!.chats, - icon: const Icon(Icons.chat_outlined), - ), - if (rooms.isEmpty && !widget.controller.isSearchMode) - Column( - key: const ValueKey(null), - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - Image.asset( - 'assets/private_chat_wallpaper.png', - width: 160, - height: 160, - ), - Center( - child: Text( - L10n.of(context)!.startYourFirstChat, - textAlign: TextAlign.start, - style: const TextStyle( - color: Colors.grey, - fontSize: 16, - ), + ), + if (widget.controller.isSearchMode) + SearchTitle( + title: L10n.of(context)!.chats, + icon: const Icon(Icons.chat_outlined), + ), + if (rooms.isEmpty && !widget.controller.isSearchMode) + Column( + key: const ValueKey(null), + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Image.asset( + 'assets/private_chat_wallpaper.png', + width: 160, + height: 160, + ), + Center( + child: Text( + L10n.of(context)!.startYourFirstChat, + textAlign: TextAlign.start, + style: const TextStyle( + color: Colors.grey, + fontSize: 16, ), ), - const SizedBox(height: 16), - ], - ), - ], - ); - } - i--; - } - if (i >= rooms.length) { - return SpacesHierarchyProposals( - space: widget.controller.activeSpacesEntry.getSpace(context)?.id, - query: widget.controller.isSearchMode - ? widget.controller.searchController.text - : null, + ), + const SizedBox(height: 16), + ], + ), + ], ); } + i--; if (!rooms[i].displayname.toLowerCase().contains( widget.controller.searchController.text.toLowerCase())) { return Container(); @@ -220,6 +205,12 @@ class _ChatListViewBodyState extends State { ); }, ); + } else if (widget.controller.activeFilter == ActiveFilter.spaces) { + child = SpaceView( + widget.controller, + scrollController: widget.controller.scrollController, + key: Key(widget.controller.activeSpaceId ?? 'Spaces'), + ); } else { const dummyChatCount = 5; final titleColor = @@ -227,6 +218,7 @@ class _ChatListViewBodyState extends State { final subtitleColor = Theme.of(context).textTheme.bodyText1!.color!.withAlpha(50); child = ListView.builder( + key: const Key('dummychats'), itemCount: dummyChatCount, itemBuilder: (context, i) => Opacity( opacity: (dummyChatCount - i) / dummyChatCount, @@ -282,7 +274,6 @@ class _ChatListViewBodyState extends State { ); } return PageTransitionSwitcher( - reverse: reversed, transitionBuilder: ( Widget child, Animation primaryAnimation, @@ -306,30 +297,6 @@ class _ChatListViewBodyState extends State { super.dispose(); } - bool _animationReversed() { - bool reversed; - // in case the matrix id changes, check the indexOf the matrix id - final newClient = Matrix.of(context).client; - if (_lastUserId != newClient.userID) { - reversed = Matrix.of(context) - .currentBundle! - .indexWhere((element) => element!.userID == _lastUserId) < - Matrix.of(context) - .currentBundle! - .indexWhere((element) => element!.userID == newClient.userID); - } - // otherwise, the space changed... - else { - reversed = widget.controller.spacesEntries - .indexWhere((element) => element == _lastSpace) < - widget.controller.spacesEntries.indexWhere( - (element) => element == widget.controller.activeSpacesEntry); - } - _lastUserId = newClient.userID; - _lastSpace = widget.controller.activeSpacesEntry; - return reversed; - } - @override void didUpdateWidget(covariant ChatListViewBody oldWidget) { setState(() {}); @@ -337,57 +304,6 @@ class _ChatListViewBodyState extends State { } } -class SpaceRoomListTopBar extends StatefulWidget { - final ChatListController controller; - - const SpaceRoomListTopBar(this.controller, {Key? key}) : super(key: key); - - @override - State createState() => _SpaceRoomListTopBarState(); -} - -class _SpaceRoomListTopBarState extends State { - bool _limitSize = true; - - @override - Widget build(BuildContext context) { - if (widget.controller.activeSpacesEntry is SpaceSpacesEntry && - !widget.controller.isSearchMode && - (widget.controller.activeSpacesEntry as SpaceSpacesEntry) - .space - .topic - .isNotEmpty) { - return GestureDetector( - onTap: () => setState(() { - _limitSize = !_limitSize; - }), - child: Column( - children: [ - Padding( - padding: const EdgeInsets.all(8), - child: LinkText( - text: (widget.controller.activeSpacesEntry as SpaceSpacesEntry) - .space - .topic, - maxLines: _limitSize ? 3 : null, - linkStyle: const TextStyle(color: Colors.blueAccent), - textStyle: TextStyle( - fontSize: 14, - color: Theme.of(context).textTheme.bodyText2!.color, - ), - onLinkTap: (url) => UrlLauncher(context, url).launchUrl(), - ), - ), - const Divider(), - ], - ), - ); - } else { - return Container(); - } - } -} - class _SearchItem extends StatelessWidget { final String title; final Uri? avatar; diff --git a/lib/pages/chat_list/chat_list_drawer.dart b/lib/pages/chat_list/chat_list_drawer.dart deleted file mode 100644 index a1c79b4f..00000000 --- a/lib/pages/chat_list/chat_list_drawer.dart +++ /dev/null @@ -1,85 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:flutter_gen/gen_l10n/l10n.dart'; -import 'package:vrouter/vrouter.dart'; - -import 'package:fluffychat/pages/chat_list/chat_list.dart'; -import 'package:fluffychat/pages/chat_list/spaces_drawer.dart'; -import 'package:fluffychat/utils/fluffy_share.dart'; -import 'package:fluffychat/widgets/avatar.dart'; -import 'package:fluffychat/widgets/matrix.dart'; -import '../../config/app_config.dart'; - -class ChatListDrawer extends StatelessWidget { - final ChatListController controller; - const ChatListDrawer(this.controller, {Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) => Drawer( - child: SafeArea( - child: Column( - children: [ - ListTile( - leading: const CircleAvatar( - radius: Avatar.defaultSize / 2, - backgroundImage: AssetImage('assets/logo.png'), - ), - title: Text(AppConfig.applicationName), - trailing: Icon( - Icons.adaptive.share_outlined, - color: Theme.of(context).colorScheme.onBackground, - ), - onTap: () { - Scaffold.of(context).closeDrawer(); - FluffyShare.share( - L10n.of(context)!.inviteText( - Matrix.of(context).client.userID!, - 'https://matrix.to/#/${Matrix.of(context).client.userID}?client=im.fluffychat'), - context); - }, - ), - const Divider(thickness: 1), - Expanded( - child: SpacesDrawer( - controller: controller, - ), - ), - const Divider(thickness: 1), - ListTile( - leading: Icon( - Icons.group_add_outlined, - color: Theme.of(context).colorScheme.onBackground, - ), - title: Text(L10n.of(context)!.createNewGroup), - onTap: () { - Scaffold.of(context).closeDrawer(); - VRouter.of(context).to('/newgroup'); - }, - ), - ListTile( - leading: Icon( - Icons.group_work_outlined, - color: Theme.of(context).colorScheme.onBackground, - ), - title: Text(L10n.of(context)!.createNewSpace), - onTap: () { - Scaffold.of(context).closeDrawer(); - VRouter.of(context).to('/newspace'); - }, - ), - ListTile( - leading: Icon( - Icons.settings_outlined, - color: Theme.of(context).colorScheme.onBackground, - ), - title: Text(L10n.of(context)!.settings), - onTap: () { - Scaffold.of(context).closeDrawer(); - VRouter.of(context).to('/settings'); - }, - ), - ], - ), - ), - ); -} diff --git a/lib/pages/chat_list/chat_list_header.dart b/lib/pages/chat_list/chat_list_header.dart index 7f6219b7..61a4cae3 100644 --- a/lib/pages/chat_list/chat_list_header.dart +++ b/lib/pages/chat_list/chat_list_header.dart @@ -1,12 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; -import 'package:vrouter/vrouter.dart'; import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/pages/chat_list/chat_list.dart'; import 'package:fluffychat/pages/chat_list/client_chooser_button.dart'; -import 'package:fluffychat/widgets/matrix.dart'; +import '../../widgets/matrix.dart'; class ChatListHeader extends StatelessWidget implements PreferredSizeWidget { final ChatListController controller; @@ -53,39 +52,25 @@ class ChatListHeader extends StatelessWidget implements PreferredSizeWidget { borderRadius: BorderRadius.circular(AppConfig.borderRadius), ), - hintText: controller.activeSpacesEntry.getName(context), - prefixIcon: Padding( - padding: const EdgeInsets.only( - left: 8.0, - right: 4, - ), - child: controller.isSearchMode - ? IconButton( - tooltip: L10n.of(context)!.cancel, - icon: const Icon(Icons.close_outlined), - onPressed: controller.cancelSearch, - color: - Theme.of(context).colorScheme.onBackground, - ) - : IconButton( - onPressed: Scaffold.of(context).openDrawer, - icon: Icon( - Icons.menu, - color: Theme.of(context) - .colorScheme - .onBackground, - ), - ), - ), - suffixIcon: Row( - mainAxisSize: MainAxisSize.min, - children: controller.isSearchMode - ? [ - if (controller.isSearching) - const CircularProgressIndicator.adaptive( - strokeWidth: 2, - ), - TextButton( + hintText: L10n.of(context)!.search, + floatingLabelBehavior: FloatingLabelBehavior.never, + prefixIcon: controller.isSearchMode + ? IconButton( + tooltip: L10n.of(context)!.cancel, + icon: const Icon(Icons.close_outlined), + onPressed: controller.cancelSearch, + color: Theme.of(context).colorScheme.onBackground, + ) + : Icon( + Icons.search_outlined, + color: Theme.of(context).colorScheme.onBackground, + ), + suffixIcon: controller.isSearchMode + ? controller.isSearching + ? const CircularProgressIndicator.adaptive( + strokeWidth: 2, + ) + : TextButton( onPressed: controller.setServer, style: TextButton.styleFrom( textStyle: const TextStyle(fontSize: 12), @@ -98,24 +83,11 @@ class ChatListHeader extends StatelessWidget implements PreferredSizeWidget { .host, maxLines: 2, ), - ), - ] - : [ - IconButton( - icon: Icon( - Icons.camera_alt_outlined, - color: Theme.of(context) - .colorScheme - .onBackground, - ), - tooltip: L10n.of(context)!.addToStory, - onPressed: () => - VRouter.of(context).to('/stories/create'), - ), - ClientChooserButton(controller), - const SizedBox(width: 12), - ], - ), + ) + : SizedBox( + width: 0, + child: ClientChooserButton(controller), + ), ), ), ), @@ -126,8 +98,8 @@ class ChatListHeader extends StatelessWidget implements PreferredSizeWidget { if (controller.spaces.isNotEmpty) IconButton( tooltip: L10n.of(context)!.addToSpace, - icon: const Icon(Icons.group_work_outlined), - onPressed: controller.addOrRemoveToSpace, + icon: const Icon(Icons.workspaces_outlined), + onPressed: controller.addToSpace, ), IconButton( tooltip: L10n.of(context)!.toggleUnread, diff --git a/lib/pages/chat_list/chat_list_view.dart b/lib/pages/chat_list/chat_list_view.dart index 6913fe3b..8eb4c0b7 100644 --- a/lib/pages/chat_list/chat_list_view.dart +++ b/lib/pages/chat_list/chat_list_view.dart @@ -5,9 +5,10 @@ import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:keyboard_shortcuts/keyboard_shortcuts.dart'; import 'package:vrouter/vrouter.dart'; +import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/pages/chat_list/chat_list.dart'; -import 'package:fluffychat/pages/chat_list/chat_list_drawer.dart'; -import 'package:fluffychat/widgets/connection_status_header.dart'; +import 'package:fluffychat/widgets/avatar.dart'; import '../../widgets/matrix.dart'; import 'chat_list_body.dart'; import 'chat_list_header.dart'; @@ -18,6 +19,33 @@ class ChatListView extends StatelessWidget { const ChatListView(this.controller, {Key? key}) : super(key: key); + List getNavigationDestinations(BuildContext context) => + [ + if (AppConfig.separateChatTypes) ...[ + NavigationDestination( + icon: const Icon(Icons.groups_outlined), + selectedIcon: const Icon(Icons.groups), + label: L10n.of(context)!.groups, + ), + NavigationDestination( + icon: const Icon(Icons.chat_outlined), + selectedIcon: const Icon(Icons.chat), + label: L10n.of(context)!.messages, + ), + ] else + NavigationDestination( + icon: const Icon(Icons.chat_outlined), + selectedIcon: const Icon(Icons.chat), + label: L10n.of(context)!.chats, + ), + if (controller.spaces.isNotEmpty) + const NavigationDestination( + icon: Icon(Icons.workspaces_outlined), + selectedIcon: Icon(Icons.workspaces), + label: 'Spaces', + ), + ]; + @override Widget build(BuildContext context) { return StreamBuilder( @@ -30,24 +58,154 @@ class ChatListView extends StatelessWidget { if (selMode != SelectMode.normal) controller.cancelAction(); if (selMode == SelectMode.select) redirector.stopRedirection(); }, - child: Scaffold( - appBar: ChatListHeader(controller: controller), - body: ChatListViewBody(controller), - drawer: ChatListDrawer(controller), - bottomNavigationBar: const ConnectionStatusHeader(), - floatingActionButton: selectMode == SelectMode.normal - ? KeyBoardShortcuts( - keysToPress: { - LogicalKeyboardKey.controlLeft, - LogicalKeyboardKey.keyN - }, - onKeysPressed: () => - VRouter.of(context).to('/newprivatechat'), - helpLabel: L10n.of(context)!.newChat, - child: - StartChatFloatingActionButton(controller: controller), - ) - : null, + child: Row( + children: [ + if (FluffyThemes.isColumnMode(context) && + FluffyThemes.getDisplayNavigationRail(context)) ...[ + Builder(builder: (context) { + final client = Matrix.of(context).client; + final allSpaces = client.rooms.where((room) => room.isSpace); + final rootSpaces = allSpaces + .where( + (space) => !allSpaces.any( + (parentSpace) => parentSpace.spaceChildren + .any((child) => child.roomId == space.id), + ), + ) + .toList(); + final destinations = getNavigationDestinations(context) + ..removeLast(); + return SizedBox( + width: 64, + child: ListView.builder( + scrollDirection: Axis.vertical, + itemCount: rootSpaces.length + + 1 + + (AppConfig.separateChatTypes ? 1 : 0), + itemBuilder: (context, i) { + if (i < destinations.length) { + final isSelected = i == controller.selectedIndex; + return Container( + height: 64, + width: 64, + decoration: BoxDecoration( + color: isSelected + ? Theme.of(context) + .colorScheme + .secondaryContainer + : Theme.of(context).colorScheme.background, + border: Border( + left: BorderSide( + color: isSelected + ? Theme.of(context).colorScheme.primary + : Colors.transparent, + width: 4, + ), + right: const BorderSide( + color: Colors.transparent, + width: 4, + ), + ), + ), + alignment: Alignment.center, + child: IconButton( + color: isSelected + ? Theme.of(context).colorScheme.primary + : null, + icon: CircleAvatar( + backgroundColor: Theme.of(context) + .colorScheme + .secondaryContainer, + foregroundColor: Theme.of(context) + .colorScheme + .onSecondaryContainer, + child: i == controller.selectedIndex + ? destinations[i].selectedIcon ?? + destinations[i].icon + : destinations[i].icon), + tooltip: destinations[i].label, + onPressed: () => + controller.onDestinationSelected(i), + ), + ); + } + i -= destinations.length; + final isSelected = + controller.activeFilter == ActiveFilter.spaces && + rootSpaces[i].id == controller.activeSpaceId; + return Container( + height: 64, + width: 64, + decoration: BoxDecoration( + color: isSelected + ? Theme.of(context) + .colorScheme + .secondaryContainer + : Theme.of(context).colorScheme.background, + border: Border( + left: BorderSide( + color: isSelected + ? Theme.of(context).colorScheme.primary + : Colors.transparent, + width: 4, + ), + right: const BorderSide( + color: Colors.transparent, + width: 4, + ), + ), + ), + alignment: Alignment.center, + child: IconButton( + tooltip: rootSpaces[i].displayname, + icon: Avatar( + mxContent: rootSpaces[i].avatar, + name: rootSpaces[i].displayname, + size: 32, + fontSize: 12, + ), + onPressed: () => + controller.setActiveSpace(rootSpaces[i].id), + ), + ); + }, + ), + ); + }), + Container( + color: Theme.of(context).dividerColor, + width: 1, + ), + ], + Expanded( + child: Scaffold( + appBar: ChatListHeader(controller: controller), + body: ChatListViewBody(controller), + bottomNavigationBar: controller.displayNavigationBar + ? NavigationBar( + height: 64, + selectedIndex: controller.selectedIndex, + onDestinationSelected: + controller.onDestinationSelected, + destinations: getNavigationDestinations(context), + ) + : null, + floatingActionButton: selectMode == SelectMode.normal + ? KeyBoardShortcuts( + keysToPress: { + LogicalKeyboardKey.controlLeft, + LogicalKeyboardKey.keyN + }, + onKeysPressed: () => + VRouter.of(context).to('/newprivatechat'), + helpLabel: L10n.of(context)!.newChat, + child: StartChatFloatingActionButton( + controller: controller), + ) + : null, + ), + ), + ], ), ); }, diff --git a/lib/pages/chat_list/client_chooser_button.dart b/lib/pages/chat_list/client_chooser_button.dart index 76889af9..c68adcb9 100644 --- a/lib/pages/chat_list/client_chooser_button.dart +++ b/lib/pages/chat_list/client_chooser_button.dart @@ -4,9 +4,11 @@ import 'package:flutter/services.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:keyboard_shortcuts/keyboard_shortcuts.dart'; import 'package:matrix/matrix.dart'; +import 'package:vrouter/vrouter.dart'; import 'package:fluffychat/widgets/avatar.dart'; import 'package:fluffychat/widgets/matrix.dart'; +import '../../utils/fluffy_share.dart'; import 'chat_list.dart'; class ClientChooserButton extends StatelessWidget { @@ -23,6 +25,60 @@ class ClientChooserButton extends StatelessWidget { ? -1 : 1); return >[ + PopupMenuItem( + value: SettingsAction.newStory, + child: Row( + children: [ + const Icon(Icons.camera_outlined), + const SizedBox(width: 18), + Text(L10n.of(context)!.yourStory), + ], + ), + ), + PopupMenuItem( + value: SettingsAction.newGroup, + child: Row( + children: [ + const Icon(Icons.group_add_outlined), + const SizedBox(width: 18), + Text(L10n.of(context)!.createNewGroup), + ], + ), + ), + PopupMenuItem( + value: SettingsAction.newSpace, + child: Row( + children: [ + const Icon(Icons.workspaces_outlined), + const SizedBox(width: 18), + Text(L10n.of(context)!.createNewSpace), + ], + ), + ), + PopupMenuItem( + value: SettingsAction.invite, + child: Row( + children: [ + Icon(Icons.adaptive.share_outlined), + const SizedBox(width: 18), + Text(L10n.of(context)!.inviteContact), + ], + ), + ), + PopupMenuItem( + value: SettingsAction.settings, + child: Row( + children: [ + const Icon(Icons.settings_outlined), + const SizedBox(width: 18), + Text(L10n.of(context)!.settings), + ], + ), + ), + const PopupMenuItem( + value: null, + child: Divider(height: 1), + ), for (final bundle in bundles) ...[ if (matrix.accountBundles[bundle]!.length != 1 || matrix.accountBundles[bundle]!.single!.userID != bundle) @@ -80,7 +136,7 @@ class ClientChooserButton extends StatelessWidget { .toList(), ], PopupMenuItem( - value: AddAccountAction.addAccount, + value: SettingsAction.addAccount, child: Row( children: [ const Icon(Icons.person_add_outlined), @@ -98,42 +154,50 @@ class ClientChooserButton extends StatelessWidget { int clientCount = 0; matrix.accountBundles.forEach((key, value) => clientCount += value.length); - return Center( - child: FutureBuilder( - future: matrix.client.fetchOwnProfile(), - builder: (context, snapshot) => Stack( - alignment: Alignment.center, - children: [ - ...List.generate( - clientCount, - (index) => KeyBoardShortcuts( - keysToPress: _buildKeyboardShortcut(index + 1), - helpLabel: L10n.of(context)!.switchToAccount(index + 1), - onKeysPressed: () => _handleKeyboardShortcut(matrix, index), - child: Container(), + return FutureBuilder( + future: matrix.client.fetchOwnProfile(), + builder: (context, snapshot) => Stack( + alignment: Alignment.center, + children: [ + ...List.generate( + clientCount, + (index) => KeyBoardShortcuts( + keysToPress: _buildKeyboardShortcut(index + 1), + helpLabel: L10n.of(context)!.switchToAccount(index + 1), + onKeysPressed: () => _handleKeyboardShortcut( + matrix, + index, + context, ), - ), - KeyBoardShortcuts( - keysToPress: { - LogicalKeyboardKey.controlLeft, - LogicalKeyboardKey.tab - }, - helpLabel: L10n.of(context)!.nextAccount, - onKeysPressed: () => _nextAccount(matrix), child: Container(), ), - KeyBoardShortcuts( - keysToPress: { - LogicalKeyboardKey.controlLeft, - LogicalKeyboardKey.shiftLeft, - LogicalKeyboardKey.tab - }, - helpLabel: L10n.of(context)!.previousAccount, - onKeysPressed: () => _previousAccount(matrix), - child: Container(), - ), - PopupMenuButton( - onSelected: _clientSelected, + ), + KeyBoardShortcuts( + keysToPress: { + LogicalKeyboardKey.controlLeft, + LogicalKeyboardKey.tab + }, + helpLabel: L10n.of(context)!.nextAccount, + onKeysPressed: () => _nextAccount(matrix, context), + child: Container(), + ), + KeyBoardShortcuts( + keysToPress: { + LogicalKeyboardKey.controlLeft, + LogicalKeyboardKey.shiftLeft, + LogicalKeyboardKey.tab + }, + helpLabel: L10n.of(context)!.previousAccount, + onKeysPressed: () => _previousAccount(matrix, context), + child: Container(), + ), + Theme( + data: Theme.of(context), + child: PopupMenuButton( + shape: Border.all( + color: Theme.of(context).dividerColor, + ), + onSelected: (o) => _clientSelected(o, context), itemBuilder: _bundleMenuItems, child: Material( color: Colors.transparent, @@ -147,8 +211,8 @@ class ClientChooserButton extends StatelessWidget { ), ), ), - ], - ), + ), + ], ), ); } @@ -164,17 +228,46 @@ class ClientChooserButton extends StatelessWidget { } } - void _clientSelected(Object object) { + void _clientSelected( + Object object, + BuildContext context, + ) { if (object is Client) { controller.setActiveClient(object); } else if (object is String) { controller.setActiveBundle(object); - } else if (object == AddAccountAction.addAccount) { - controller.addAccountAction(); + } else if (object is SettingsAction) { + switch (object) { + case SettingsAction.addAccount: + VRouter.of(context).to('/settings/account'); + break; + case SettingsAction.newStory: + VRouter.of(context).to('/stories/create'); + break; + case SettingsAction.newGroup: + VRouter.of(context).to('/newgroup'); + break; + case SettingsAction.newSpace: + VRouter.of(context).to('/newspace'); + break; + case SettingsAction.invite: + FluffyShare.share( + L10n.of(context)!.inviteText(Matrix.of(context).client.userID!, + 'https://matrix.to/#/${Matrix.of(context).client.userID}?client=im.fluffychat'), + context); + break; + case SettingsAction.settings: + VRouter.of(context).to('/settings'); + break; + } } } - void _handleKeyboardShortcut(MatrixState matrix, int index) { + void _handleKeyboardShortcut( + MatrixState matrix, + int index, + BuildContext context, + ) { final bundles = matrix.accountBundles.keys.toList() ..sort((a, b) => a!.isValidMatrixId == b!.isValidMatrixId ? 0 @@ -186,20 +279,20 @@ class ClientChooserButton extends StatelessWidget { int clientCount = 0; matrix.accountBundles .forEach((key, value) => clientCount += value.length); - _handleKeyboardShortcut(matrix, clientCount); + _handleKeyboardShortcut(matrix, clientCount, context); } for (final bundleName in bundles) { final bundle = matrix.accountBundles[bundleName]; if (bundle != null) { if (index < bundle.length) { - return _clientSelected(bundle[index]!); + return _clientSelected(bundle[index]!, context); } else { index -= bundle.length; } } } // if index too high, restarting from 0 - _handleKeyboardShortcut(matrix, 0); + _handleKeyboardShortcut(matrix, 0, context); } int? _shortcutIndexOfClient(MatrixState matrix, Client client) { @@ -223,17 +316,24 @@ class ClientChooserButton extends StatelessWidget { return null; } - void _nextAccount(MatrixState matrix) { + void _nextAccount(MatrixState matrix, BuildContext context) { final client = matrix.client; final lastIndex = _shortcutIndexOfClient(matrix, client); - _handleKeyboardShortcut(matrix, lastIndex! + 1); + _handleKeyboardShortcut(matrix, lastIndex! + 1, context); } - void _previousAccount(MatrixState matrix) { + void _previousAccount(MatrixState matrix, BuildContext context) { final client = matrix.client; final lastIndex = _shortcutIndexOfClient(matrix, client); - _handleKeyboardShortcut(matrix, lastIndex! - 1); + _handleKeyboardShortcut(matrix, lastIndex! - 1, context); } } -enum AddAccountAction { addAccount } +enum SettingsAction { + addAccount, + newStory, + newGroup, + newSpace, + invite, + settings, +} diff --git a/lib/pages/chat_list/recommended_room_list_item.dart b/lib/pages/chat_list/recommended_room_list_item.dart deleted file mode 100644 index 0abe574b..00000000 --- a/lib/pages/chat_list/recommended_room_list_item.dart +++ /dev/null @@ -1,119 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:flutter_gen/gen_l10n/l10n.dart'; -import 'package:matrix/matrix.dart'; - -import 'package:fluffychat/pages/chat_list/spaces_hierarchy_proposal.dart'; -import 'package:fluffychat/widgets/avatar.dart'; -import 'package:fluffychat/widgets/public_room_bottom_sheet.dart'; - -class RecommendedRoomListItem extends StatelessWidget { - final SpaceRoomsChunk room; - final VoidCallback onRoomJoined; - - const RecommendedRoomListItem({ - Key? key, - required this.room, - required this.onRoomJoined, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - final leading = Avatar( - mxContent: room.avatarUrl, - name: room.name, - ); - final title = Row( - children: [ - Expanded( - child: Text( - room.name ?? '', - maxLines: 1, - overflow: TextOverflow.ellipsis, - softWrap: false, - style: TextStyle( - fontWeight: FontWeight.bold, - color: Theme.of(context).textTheme.bodyText1!.color, - ), - ), - ), - // number of joined users - Padding( - padding: const EdgeInsets.only(left: 4.0), - child: Text.rich( - TextSpan(children: [ - WidgetSpan( - child: Tooltip( - message: L10n.of(context)! - .numberRoomMembers(room.numJoinedMembers), - child: const Icon( - Icons.people_outlined, - size: 20, - ), - ), - alignment: PlaceholderAlignment.middle, - baseline: TextBaseline.alphabetic), - TextSpan(text: ' ${room.numJoinedMembers}') - ]), - style: TextStyle( - fontSize: 13, - color: Theme.of(context).textTheme.bodyText2!.color, - ), - ), - ), - ], - ); - final subtitle = room.topic != null - ? Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Expanded( - child: Text( - room.topic!, - softWrap: false, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: Theme.of(context).textTheme.bodyText2!.color, - ), - ), - ), - ], - ) - : null; - void handler() => showModalBottomSheet( - context: context, - builder: (c) => PublicRoomBottomSheet( - outerContext: context, - chunk: room, - onRoomJoined: onRoomJoined, - ), - ); - if (room.roomType == 'm.space') { - return Material( - color: Colors.transparent, - child: ExpansionTile( - leading: leading, - title: title, - subtitle: subtitle, - onExpansionChanged: (open) { - if (!open) handler(); - }, - children: [ - SpacesHierarchyProposals(space: room.roomId), - ], - ), - ); - } else { - return Material( - color: Colors.transparent, - child: ListTile( - leading: leading, - title: title, - subtitle: subtitle, - onTap: handler, - ), - ); - } - } -} diff --git a/lib/pages/chat_list/search_title.dart b/lib/pages/chat_list/search_title.dart index 7492de0b..0aceb431 100644 --- a/lib/pages/chat_list/search_title.dart +++ b/lib/pages/chat_list/search_title.dart @@ -5,12 +5,14 @@ class SearchTitle extends StatelessWidget { final Widget icon; final Widget? trailing; final void Function()? onTap; + final Color? color; const SearchTitle({ required this.title, required this.icon, this.trailing, this.onTap, + this.color, Key? key, }) : super(key: key); @@ -26,7 +28,7 @@ class SearchTitle extends StatelessWidget { width: 1, ), ), - color: Theme.of(context).colorScheme.surface, + color: color ?? Theme.of(context).colorScheme.surface, child: InkWell( onTap: onTap, splashColor: Theme.of(context).colorScheme.surface, diff --git a/lib/pages/chat_list/space_view.dart b/lib/pages/chat_list/space_view.dart new file mode 100644 index 00000000..a8f3149e --- /dev/null +++ b/lib/pages/chat_list/space_view.dart @@ -0,0 +1,309 @@ +import 'package:flutter/material.dart'; + +import 'package:adaptive_dialog/adaptive_dialog.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:future_loading_dialog/future_loading_dialog.dart'; +import 'package:matrix/matrix.dart'; +import 'package:vrouter/vrouter.dart'; + +import 'package:fluffychat/config/themes.dart'; +import 'package:fluffychat/pages/chat_list/chat_list.dart'; +import 'package:fluffychat/pages/chat_list/chat_list_item.dart'; +import 'package:fluffychat/pages/chat_list/search_title.dart'; +import 'package:fluffychat/widgets/avatar.dart'; +import '../../utils/localized_exception_extension.dart'; +import '../../widgets/matrix.dart'; + +class SpaceView extends StatefulWidget { + final ChatListController controller; + final ScrollController scrollController; + const SpaceView( + this.controller, { + Key? key, + required this.scrollController, + }) : super(key: key); + + @override + State createState() => _SpaceViewState(); +} + +class _SpaceViewState extends State { + static final Map> _requests = {}; + + void _refresh() { + setState(() { + _requests.remove(widget.controller.activeSpaceId); + }); + } + + Future getFuture(String activeSpaceId) => + _requests[activeSpaceId] ??= + Matrix.of(context).client.getSpaceHierarchy(activeSpaceId); + + void _onJoinSpaceChild(SpaceRoomsChunk spaceChild) async { + final client = Matrix.of(context).client; + final space = client.getRoomById(widget.controller.activeSpaceId!); + if (client.getRoomById(spaceChild.roomId) == null) { + final result = await showFutureLoadingDialog( + context: context, + future: () async { + await client.joinRoom(spaceChild.roomId, + serverName: space?.spaceChildren + .firstWhereOrNull( + (child) => child.roomId == spaceChild.roomId) + ?.via); + if (client.getRoomById(spaceChild.roomId) == null) { + // Wait for room actually appears in sync + await client.waitForRoomInSync(spaceChild.roomId, join: true); + } + }, + ); + if (result.error != null) return; + _refresh(); + } + if (spaceChild.roomType == 'm.space') { + if (spaceChild.roomId == widget.controller.activeSpaceId) { + VRouter.of(context).toSegments(['spaces', spaceChild.roomId]); + } else { + widget.controller.setActiveSpace(spaceChild.roomId); + } + return; + } + VRouter.of(context).toSegments(['rooms', spaceChild.roomId]); + } + + void _onSpaceChildContextMenu( + [SpaceRoomsChunk? spaceChild, Room? room]) async { + final client = Matrix.of(context).client; + final activeSpaceId = widget.controller.activeSpaceId; + final activeSpace = + activeSpaceId == null ? null : client.getRoomById(activeSpaceId); + final action = await showModalActionSheet( + context: context, + title: spaceChild?.name ?? room?.displayname, + message: spaceChild?.topic ?? room?.topic, + actions: [ + if (room == null) + SheetAction( + key: SpaceChildContextAction.join, + label: L10n.of(context)!.joinRoom, + icon: Icons.send_outlined, + ), + if (spaceChild != null && (activeSpace?.canSendDefaultStates ?? false)) + SheetAction( + key: SpaceChildContextAction.removeFromSpace, + label: L10n.of(context)!.removeFromSpace, + icon: Icons.delete_sweep_outlined, + ), + if (room != null) + SheetAction( + key: SpaceChildContextAction.leave, + label: L10n.of(context)!.leave, + icon: Icons.delete_outlined, + isDestructiveAction: true, + ), + ], + ); + if (action == null) return; + + switch (action) { + case SpaceChildContextAction.join: + _onJoinSpaceChild(spaceChild!); + break; + case SpaceChildContextAction.leave: + await showFutureLoadingDialog( + context: context, + future: room!.leave, + ); + break; + case SpaceChildContextAction.removeFromSpace: + await showFutureLoadingDialog( + context: context, + future: () => activeSpace!.removeSpaceChild(spaceChild!.roomId), + ); + break; + } + } + + @override + Widget build(BuildContext context) { + final client = Matrix.of(context).client; + final activeSpaceId = widget.controller.activeSpaceId; + final allSpaces = client.rooms.where((room) => room.isSpace); + if (activeSpaceId == null) { + final rootSpaces = allSpaces + .where( + (space) => !allSpaces.any( + (parentSpace) => parentSpace.spaceChildren + .any((child) => child.roomId == space.id), + ), + ) + .toList(); + + return ListView.builder( + itemCount: rootSpaces.length, + controller: widget.scrollController, + itemBuilder: (context, i) => ListTile( + leading: Avatar( + mxContent: rootSpaces[i].avatar, + name: rootSpaces[i].displayname, + ), + title: Text( + rootSpaces[i].displayname, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text('${rootSpaces[i].spaceChildren.length} Chats'), + onTap: () => widget.controller.setActiveSpace(rootSpaces[i].id), + onLongPress: () => _onSpaceChildContextMenu(null, rootSpaces[i]), + trailing: const Icon(Icons.chevron_right_outlined), + ), + ); + } + return FutureBuilder( + future: getFuture(activeSpaceId), + builder: (context, snapshot) { + final response = snapshot.data; + final error = snapshot.error; + if (error != null) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Padding( + padding: const EdgeInsets.all(16.0), + child: Text(error.toLocalizedString(context)), + ), + IconButton( + onPressed: _refresh, + icon: const Icon(Icons.refresh_outlined), + ) + ], + ); + } + if (response == null) { + return const Center(child: CircularProgressIndicator.adaptive()); + } + final parentSpace = allSpaces.firstWhereOrNull((space) => space + .spaceChildren + .any((child) => child.roomId == activeSpaceId)); + return ListView.builder( + itemCount: response.rooms.length + 1, + controller: widget.scrollController, + itemBuilder: (context, i) { + if (i == 0) { + return ListTile( + leading: FluffyThemes.isColumnMode(context) && + parentSpace == null + ? null + : BackButton( + onPressed: () => widget.controller + .setActiveSpace(parentSpace?.id), + ), + title: Text(parentSpace == null + ? FluffyThemes.isColumnMode(context) + ? L10n.of(context)!.showSpaces + : L10n.of(context)!.allSpaces + : parentSpace.displayname), + trailing: IconButton( + icon: snapshot.connectionState != ConnectionState.done + ? const CircularProgressIndicator.adaptive() + : const Icon(Icons.refresh_outlined), + onPressed: + snapshot.connectionState != ConnectionState.done + ? null + : _refresh, + ), + ); + } + i--; + final spaceChild = response.rooms[i]; + final room = client.getRoomById(spaceChild.roomId); + if (room != null && !room.isSpace) { + return ChatListItem( + room, + onLongPress: () => + _onSpaceChildContextMenu(spaceChild, room), + activeChat: widget.controller.activeChat == room.id, + ); + } + final isSpace = spaceChild.roomType == 'm.space'; + final topic = + spaceChild.topic?.isEmpty ?? true ? null : spaceChild.topic; + if (spaceChild.roomId == activeSpaceId) { + return SearchTitle( + title: + spaceChild.name ?? spaceChild.canonicalAlias ?? 'Space', + icon: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10.0), + child: Avatar( + size: 24, + mxContent: spaceChild.avatarUrl, + name: spaceChild.name, + fontSize: 9, + ), + ), + color: Theme.of(context) + .colorScheme + .secondaryContainer + .withAlpha(128), + trailing: const Padding( + padding: EdgeInsets.symmetric(horizontal: 16.0), + child: Icon(Icons.edit_outlined), + ), + onTap: () => _onJoinSpaceChild(spaceChild), + ); + } + return ListTile( + leading: Avatar( + mxContent: spaceChild.avatarUrl, + name: spaceChild.name, + ), + title: Row( + children: [ + Expanded( + child: Text( + spaceChild.name ?? + spaceChild.canonicalAlias ?? + L10n.of(context)!.chat, + maxLines: 1, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ), + if (!isSpace) ...[ + const Icon( + Icons.people_outline, + size: 16, + ), + const SizedBox(width: 4), + Text( + spaceChild.numJoinedMembers.toString(), + style: const TextStyle(fontSize: 14), + ), + ], + ], + ), + onTap: () => _onJoinSpaceChild(spaceChild), + onLongPress: () => _onSpaceChildContextMenu(spaceChild, room), + subtitle: Text( + topic ?? + (isSpace + ? L10n.of(context)!.enterSpace + : L10n.of(context)!.enterRoom), + maxLines: 1, + style: TextStyle( + color: Theme.of(context).colorScheme.onBackground), + ), + trailing: + isSpace ? const Icon(Icons.chevron_right_outlined) : null, + ); + }); + }); + } +} + +enum SpaceChildContextAction { + join, + leave, + removeFromSpace, +} diff --git a/lib/pages/chat_list/spaces_drawer.dart b/lib/pages/chat_list/spaces_drawer.dart deleted file mode 100644 index b2dd5c19..00000000 --- a/lib/pages/chat_list/spaces_drawer.dart +++ /dev/null @@ -1,190 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter/material.dart'; - -import 'package:collection/collection.dart'; -import 'package:flutter_gen/gen_l10n/l10n.dart'; -import 'package:matrix/matrix.dart'; -import 'package:vrouter/vrouter.dart'; - -import 'package:fluffychat/pages/chat_list/spaces_entry.dart'; -import 'package:fluffychat/widgets/avatar.dart'; -import 'package:fluffychat/widgets/matrix.dart'; -import 'chat_list.dart'; -import 'spaces_drawer_entry.dart'; - -class SpacesDrawer extends StatelessWidget { - final ChatListController controller; - - const SpacesDrawer({Key? key, required this.controller}) : super(key: key); - - @override - Widget build(BuildContext context) { - final spaceEntries = controller.spacesEntries - .map((e) => SpacesEntryMaybeChildren.buildIfTopLevel( - e, controller.spacesEntries)) - .whereNotNull() - .toList(); - - final childSpaceIds = {}; - - final spacesHierarchy = []; - - final matrix = Matrix.of(context); - for (final entry in spaceEntries) { - if (entry.spacesEntry is SpaceSpacesEntry) { - final space = entry.spacesEntry.getSpace(context); - if (space != null && space.spaceChildren.isNotEmpty) { - final children = space.spaceChildren; - // computing the children space entries - final childrenSpaceEntries = spaceEntries.where((element) { - // current ID - final id = element.spacesEntry.getSpace(context)?.id; - - // comparing against the supposed IDs of the children and checking - // whether the room is already joined - return children.any( - (child) => - child.roomId == id && - matrix.client.rooms - .any((joinedRoom) => child.roomId == joinedRoom.id), - ); - }); - childSpaceIds.addAll(childrenSpaceEntries - .map((e) => e.spacesEntry.getSpace(context)?.id) - .whereNotNull()); - entry.children.addAll(childrenSpaceEntries); - spacesHierarchy.add(entry); - } else { - // don't add rooms with parent space apart from those where the - // parent space is not joined - if (space?.hasNotJoinedParentSpace() ?? false) { - spacesHierarchy.add(entry); - } - } - } else { - spacesHierarchy.add(entry); - } - } - - spacesHierarchy.removeWhere((element) => - childSpaceIds.contains(element.spacesEntry.getSpace(context)?.id)); - - return ListView.builder( - itemCount: spacesHierarchy.length + 1, - itemBuilder: (context, i) { - if (i == spacesHierarchy.length) { - return ListTile( - leading: CircleAvatar( - radius: Avatar.defaultSize / 2, - backgroundColor: Theme.of(context).colorScheme.secondary, - foregroundColor: Theme.of(context).colorScheme.onSecondary, - child: const Icon( - Icons.archive_outlined, - ), - ), - title: Text(L10n.of(context)!.archive), - onTap: () { - Scaffold.of(context).closeDrawer(); - VRouter.of(context).to('/archive'); - }, - ); - } - final space = spacesHierarchy[i]; - return SpacesDrawerEntry( - entry: space, - controller: controller, - ); - }, - ); - } -} - -class SpacesEntryMaybeChildren { - final SpacesEntry spacesEntry; - - final Set children; - - const SpacesEntryMaybeChildren(this.spacesEntry, [this.children = const {}]); - - static SpacesEntryMaybeChildren? buildIfTopLevel( - SpacesEntry entry, List allEntries, - [String? parent]) { - if (entry is SpaceSpacesEntry) { - final room = entry.space; - // don't add rooms with parent space apart from those where the - // parent space is not joined - if ((parent == null && - room.spaceParents.isNotEmpty && - room.hasNotJoinedParentSpace()) || - (parent != null && - !room.spaceParents.any((element) => element.roomId == parent))) { - return null; - } else { - final children = allEntries - .where((element) => - element is SpaceSpacesEntry && - element.space.spaceParents.any((parent) => - parent.roomId == room.id /*&& (parent.canonical ?? true)*/)) - .toList(); - return SpacesEntryMaybeChildren( - entry, - children - .map((e) => buildIfTopLevel(e, allEntries, room.id)) - .whereNotNull() - .toSet()); - } - } else { - return SpacesEntryMaybeChildren(entry); - } - } - - bool isActiveOfChild(ChatListController controller) => - spacesEntry == controller.activeSpacesEntry || - children.any( - (element) => element.isActiveOfChild(controller), - ); - - Map toJson() => { - 'entry': spacesEntry is SpaceSpacesEntry - ? (spacesEntry as SpaceSpacesEntry).space.id - : spacesEntry.runtimeType.toString(), - if (spacesEntry is SpaceSpacesEntry) - 'rawSpaceParents': (spacesEntry as SpaceSpacesEntry) - .space - .spaceParents - .map((e) => - {'roomId': e.roomId, 'canonical': e.canonical, 'via': e.via}) - .toList(), - if (spacesEntry is SpaceSpacesEntry) - 'rawSpaceChildren': (spacesEntry as SpaceSpacesEntry) - .space - .spaceChildren - .map( - (e) => { - 'roomId': e.roomId, - 'suggested': e.suggested, - 'via': e.via, - 'order': e.order - }, - ) - .toList(), - 'children': children.map((e) => e.toJson()).toList(), - }; - - @override - String toString() { - return jsonEncode(toJson()); - } -} - -extension on Room { - bool hasNotJoinedParentSpace() { - return (spaceParents.isEmpty || - spaceParents.none( - (p0) => - (p0.canonical ?? true) && - client.rooms.map((e) => e.id).contains(p0.roomId), - )); - } -} diff --git a/lib/pages/chat_list/spaces_drawer_entry.dart b/lib/pages/chat_list/spaces_drawer_entry.dart deleted file mode 100644 index 220d5242..00000000 --- a/lib/pages/chat_list/spaces_drawer_entry.dart +++ /dev/null @@ -1,98 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:flutter_gen/gen_l10n/l10n.dart'; - -import 'package:fluffychat/pages/chat_list/chat_list.dart'; -import 'package:fluffychat/pages/chat_list/spaces_drawer.dart'; -import 'package:fluffychat/utils/space_navigator.dart'; -import 'package:fluffychat/widgets/avatar.dart'; - -class SpacesDrawerEntry extends StatelessWidget { - final SpacesEntryMaybeChildren entry; - final ChatListController controller; - - const SpacesDrawerEntry( - {Key? key, required this.entry, required this.controller}) - : super(key: key); - - @override - Widget build(BuildContext context) { - final space = entry.spacesEntry; - final room = space.getSpace(context); - - final active = controller.activeSpacesEntry == entry.spacesEntry; - final leading = room == null - ? CircleAvatar( - radius: Avatar.defaultSize / 2, - backgroundColor: Theme.of(context).colorScheme.secondary, - foregroundColor: Theme.of(context).colorScheme.onSecondary, - child: space.getIcon(active), - ) - : Avatar( - mxContent: room.avatar, - name: space.getName(context), - ); - final title = Text( - space.getName(context), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ); - final subtitle = room?.topic.isEmpty ?? true - ? null - : Tooltip( - message: room!.topic, - child: Text( - room.topic.replaceAll('\n', ' '), - softWrap: false, - overflow: TextOverflow.fade, - ), - ); - void onTap() { - SpaceNavigator.navigateToSpace(space.routeHandle); - Scaffold.of(context).closeDrawer(); - } - - final trailing = room != null - ? SizedBox( - width: 32, - child: IconButton( - splashRadius: 24, - icon: const Icon(Icons.edit_outlined), - tooltip: L10n.of(context)!.edit, - onPressed: () => controller.editSpace(context, room.id), - ), - ) - : const Icon(Icons.arrow_forward_ios_outlined); - - if (entry.children.isEmpty) { - return ListTile( - selected: active, - leading: leading, - title: title, - subtitle: subtitle, - onTap: onTap, - trailing: trailing, - ); - } else { - return ExpansionTile( - leading: leading, - initiallyExpanded: - entry.children.any((element) => entry.isActiveOfChild(controller)), - title: GestureDetector( - onTap: onTap, - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisSize: MainAxisSize.max, - children: [ - Expanded(child: title), - const SizedBox(width: 8), - trailing - ]), - ), - children: entry.children - .map((e) => SpacesDrawerEntry(entry: e, controller: controller)) - .toList(), - ); - } - } -} diff --git a/lib/pages/chat_list/spaces_entry.dart b/lib/pages/chat_list/spaces_entry.dart deleted file mode 100644 index bda07355..00000000 --- a/lib/pages/chat_list/spaces_entry.dart +++ /dev/null @@ -1,240 +0,0 @@ -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; - -import 'package:flutter_gen/gen_l10n/l10n.dart'; -import 'package:matrix/matrix.dart'; - -import 'package:fluffychat/config/app_config.dart'; -import 'package:fluffychat/utils/matrix_sdk_extensions.dart/client_stories_extension.dart'; -import '../../widgets/matrix.dart'; - -// This is not necessarily a Space, but an abstract categorization of a room. -// More to the point, it's a selectable entry that *could* be a Space. -// Note that view code is in spaces_bottom_bar.dart because of type-specific UI. -// So only really generic functions (so far, anything ChatList cares about) go here. -// If getRoom returns something non-null, then it gets the avatar and such of a Space. -// Otherwise it gets to look like All Rooms. Future work impending. -abstract class SpacesEntry { - const SpacesEntry(); - - // Gets the (translated) name of this entry. - String getName(BuildContext context); - - // Gets an icon for this entry (avoided if a space is given) - Icon getIcon(bool active) => active - ? const Icon(CupertinoIcons.chat_bubble_2_fill) - : const Icon(CupertinoIcons.chat_bubble_2); - - // If this is a specific Room, returns the space Room for various purposes. - Room? getSpace(BuildContext context) => null; - - // Gets a list of rooms - this is done as part of _ChatListViewBodyState to get the full list of rooms visible from this SpacesEntry. - List getRooms(BuildContext context); - - // Checks that this entry is still valid. - bool stillValid(BuildContext context) => true; - - // Returns true if the Stories header should be shown. - bool shouldShowStoriesHeader(BuildContext context) => false; - - String? get routeHandle; -} - -// Common room validity checks -bool _roomCheckCommon(Room room, BuildContext context) { - if (room.isSpace && room.membership == Membership.join && !room.isUnread) { - return false; - } - if (room.getState(EventTypes.RoomCreate)?.content.tryGet('type') == - ClientStoriesExtension.storiesRoomType) { - return false; - } - return true; -} - -bool _roomInsideSpace(Room room, Room space) { - if (space.spaceChildren.any((child) => child.roomId == room.id)) { - return true; - } - if (room.spaceParents.any((parent) => parent.roomId == space.id)) { - return true; - } - return false; -} - -// "All rooms" entry. -class AllRoomsSpacesEntry extends SpacesEntry { - static final AllRoomsSpacesEntry _value = AllRoomsSpacesEntry._(); - - AllRoomsSpacesEntry._(); - - factory AllRoomsSpacesEntry() { - return _value; - } - - @override - String getName(BuildContext context) => L10n.of(context)!.allChats; - - @override - List getRooms(BuildContext context) { - return Matrix.of(context) - .client - .rooms - .where((room) => _roomCheckCommon(room, context)) - .toList(); - } - - @override - final String? routeHandle = null; - - @override - bool shouldShowStoriesHeader(BuildContext context) => true; - - @override - bool operator ==(Object other) { - return runtimeType == other.runtimeType; - } - - @override - int get hashCode => runtimeType.hashCode; -} - -// "Direct Chats" entry. -class DirectChatsSpacesEntry extends SpacesEntry { - static final DirectChatsSpacesEntry _value = DirectChatsSpacesEntry._(); - - DirectChatsSpacesEntry._(); - - factory DirectChatsSpacesEntry() { - return _value; - } - - @override - String getName(BuildContext context) => L10n.of(context)!.directChats; - - @override - List getRooms(BuildContext context) { - return Matrix.of(context) - .client - .rooms - .where((room) => room.isDirectChat && _roomCheckCommon(room, context)) - .toList(); - } - - @override - final String? routeHandle = null; - - @override - bool shouldShowStoriesHeader(BuildContext context) => true; - - @override - bool operator ==(Object other) { - return runtimeType == other.runtimeType; - } - - @override - int get hashCode => runtimeType.hashCode; -} - -// "Groups" entry. -class GroupsSpacesEntry extends SpacesEntry { - static final GroupsSpacesEntry _value = GroupsSpacesEntry._(); - - GroupsSpacesEntry._(); - - factory GroupsSpacesEntry() { - return _value; - } - - @override - String getName(BuildContext context) => L10n.of(context)!.groups; - - @override - Icon getIcon(bool active) => - active ? const Icon(Icons.group) : const Icon(Icons.group_outlined); - - @override - List getRooms(BuildContext context) { - final rooms = Matrix.of(context).client.rooms; - // Needs to match ChatList's definition of a space. - final spaces = rooms.where((room) => room.isSpace).toList(); - return rooms - .where((room) => - (!room.isDirectChat) && - _roomCheckCommon(room, context) && - separatedGroup(room, spaces)) - .toList(); - } - - @override - final String? routeHandle = 'groups'; - - bool separatedGroup(Room room, List spaces) { - return !spaces.any((space) => _roomInsideSpace(room, space)); - } - - @override - bool operator ==(Object other) { - return runtimeType == other.runtimeType; - } - - @override - int get hashCode => runtimeType.hashCode; -} - -// All rooms associated with a specific space. -class SpaceSpacesEntry extends SpacesEntry { - final Room space; - - const SpaceSpacesEntry(this.space); - - @override - String getName(BuildContext context) => space.displayname; - - @override - Room? getSpace(BuildContext context) => space; - - @override - List getRooms(BuildContext context) { - return Matrix.of(context) - .client - .rooms - .where((room) => roomCheck(room, context)) - .toList(); - } - - bool roomCheck(Room room, BuildContext context) { - if (!_roomCheckCommon(room, context)) { - return false; - } - if (_roomInsideSpace(room, space)) { - return true; - } - if (AppConfig.showDirectChatsInSpaces) { - if (room.isDirectChat && - room.summary.mHeroes != null && - room.summary.mHeroes!.any((userId) { - final user = space.getState(EventTypes.RoomMember, userId)?.asUser; - return user != null && user.membership == Membership.join; - })) { - return true; - } - } - return false; - } - - @override - bool stillValid(BuildContext context) => - Matrix.of(context).client.getRoomById(space.id) != null; - - @override - String? get routeHandle => space.id; - - @override - bool operator ==(Object other) { - return hashCode == other.hashCode; - } - - @override - int get hashCode => space.id.hashCode; -} diff --git a/lib/pages/chat_list/spaces_hierarchy_proposal.dart b/lib/pages/chat_list/spaces_hierarchy_proposal.dart deleted file mode 100644 index 0280d018..00000000 --- a/lib/pages/chat_list/spaces_hierarchy_proposal.dart +++ /dev/null @@ -1,156 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; - -import 'package:animations/animations.dart'; -import 'package:async/async.dart'; -import 'package:flutter_gen/gen_l10n/l10n.dart'; -import 'package:matrix/matrix.dart'; - -import 'package:fluffychat/pages/chat_list/search_title.dart'; -import 'package:fluffychat/widgets/matrix.dart'; -import 'recommended_room_list_item.dart'; - -class SpacesHierarchyProposals extends StatefulWidget { - static final Map> _cache = {}; - - final String? space; - final String? query; - - const SpacesHierarchyProposals({ - Key? key, - required this.space, - this.query, - }) : super(key: key); - - @override - State createState() => - _SpacesHierarchyProposalsState(); -} - -class _SpacesHierarchyProposalsState extends State { - @override - void didUpdateWidget(covariant SpacesHierarchyProposals oldWidget) { - if (oldWidget.space != widget.space || oldWidget.query != widget.query) { - setState(() {}); - } - super.didUpdateWidget(oldWidget); - } - - @override - Widget build(BuildContext context) { - // check for recommended rooms in case the active space is a [SpaceSpacesEntry] - if (widget.space != null) { - final client = Matrix.of(context).client; - - final cache = SpacesHierarchyProposals._cache[widget.space!] ??= - AsyncCache(const Duration(minutes: 15)); - - /// additionally saving the future's state in the completer in order to - /// display the loading indicator when refreshing as a [FutureBuilder] is - /// a [StatefulWidget]. - final completer = Completer(); - final future = cache.fetch(() => client.getSpaceHierarchy( - widget.space!, - suggestedOnly: true, - maxDepth: 1, - )); - future.then(completer.complete); - - return FutureBuilder( - future: future, - builder: (context, snapshot) { - Widget child; - if (snapshot.hasData) { - final thereWereRooms = snapshot.data!.rooms.isNotEmpty; - final rooms = snapshot.data!.rooms.where( - (element) => - element.roomId != widget.space && - // filtering in case a query is given - (widget.query != null - ? (element.name?.contains(widget.query!) ?? false) || - (element.topic?.contains(widget.query!) ?? false) - // in case not, just leave it... - : true) && - client.rooms - .every((knownRoom) => element.roomId != knownRoom.id), - ); - if (rooms.isEmpty && !thereWereRooms) { - child = const ListTile(key: ValueKey(false)); - } - child = Column( - key: ValueKey(widget.space), - mainAxisSize: MainAxisSize.min, - children: [ - SearchTitle( - title: L10n.of(context)!.suggestedRooms, - icon: const Icon(Icons.auto_awesome_outlined), - trailing: completer.isCompleted - ? const Icon( - Icons.refresh_outlined, - size: 16, - ) - : const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator.adaptive( - strokeWidth: 1, - ), - ), - onTap: _refreshRooms, - ), - if (rooms.isEmpty && thereWereRooms) - ListTile( - leading: const Icon(Icons.info), - title: Text(L10n.of(context)!.allSuggestedRoomsJoined), - ), - ...rooms.map( - (e) => RecommendedRoomListItem( - room: e, - onRoomJoined: _refreshRooms, - ), - ), - ], - ); - } else { - child = Column( - key: const ValueKey(null), - children: [ - if (!snapshot.hasError) const LinearProgressIndicator(), - const ListTile(), - ], - ); - } - return PageTransitionSwitcher( - // prevent the animation from re-building on dependency change - key: ValueKey(widget.space), - transitionBuilder: ( - Widget child, - Animation primaryAnimation, - Animation secondaryAnimation, - ) { - return SharedAxisTransition( - animation: primaryAnimation, - secondaryAnimation: secondaryAnimation, - transitionType: SharedAxisTransitionType.scaled, - fillColor: Colors.transparent, - child: child, - ); - }, - layoutBuilder: (children) => Stack( - alignment: Alignment.topCenter, - children: children, - ), - child: child, - ); - }, - ); - } else { - return Container(); - } - } - - void _refreshRooms() => setState( - () => SpacesHierarchyProposals._cache[widget.space!]!.invalidate(), - ); -} diff --git a/lib/pages/chat_list/start_chat_fab.dart b/lib/pages/chat_list/start_chat_fab.dart index 35b275e6..033ceb40 100644 --- a/lib/pages/chat_list/start_chat_fab.dart +++ b/lib/pages/chat_list/start_chat_fab.dart @@ -1,7 +1,5 @@ -import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; -import 'package:animations/animations.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:vrouter/vrouter.dart'; @@ -13,39 +11,68 @@ class StartChatFloatingActionButton extends StatelessWidget { const StartChatFloatingActionButton({Key? key, required this.controller}) : super(key: key); + void _onPressed(BuildContext context) { + switch (controller.activeFilter) { + case ActiveFilter.allChats: + case ActiveFilter.messages: + VRouter.of(context).to('/newprivatechat'); + break; + case ActiveFilter.groups: + VRouter.of(context).to('/newgroup'); + break; + case ActiveFilter.spaces: + VRouter.of(context).to('/newspace'); + break; + } + } + + IconData get icon { + switch (controller.activeFilter) { + case ActiveFilter.allChats: + case ActiveFilter.messages: + return Icons.edit_outlined; + case ActiveFilter.groups: + return Icons.group_add_outlined; + case ActiveFilter.spaces: + return Icons.workspaces_outlined; + } + } + + String getLabel(BuildContext context) { + switch (controller.activeFilter) { + case ActiveFilter.allChats: + case ActiveFilter.messages: + return L10n.of(context)!.newChat; + case ActiveFilter.groups: + return L10n.of(context)!.newGroup; + case ActiveFilter.spaces: + return L10n.of(context)!.newSpace; + } + } + @override Widget build(BuildContext context) { - return PageTransitionSwitcher( - reverse: !controller.scrolledToTop, - transitionBuilder: ( - Widget child, - Animation primaryAnimation, - Animation secondaryAnimation, - ) { - return SharedAxisTransition( - animation: primaryAnimation, - secondaryAnimation: secondaryAnimation, - transitionType: SharedAxisTransitionType.horizontal, - fillColor: Colors.transparent, - child: child, - ); - }, - layoutBuilder: (children) => Stack( - alignment: Alignment.centerRight, - children: children, - ), - child: FloatingActionButton.extended( - key: ValueKey(controller.scrolledToTop), - isExtended: controller.scrolledToTop, - backgroundColor: Theme.of(context).colorScheme.primary, - foregroundColor: Theme.of(context).colorScheme.onPrimary, - onPressed: () => VRouter.of(context).to('/newprivatechat'), - icon: const Icon(CupertinoIcons.chat_bubble), - label: Text( - L10n.of(context)!.newChat, - overflow: TextOverflow.fade, - ), - ), + return AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + width: controller.scrolledToTop ? 144 : 64, + child: controller.scrolledToTop + ? FloatingActionButton.extended( + backgroundColor: Theme.of(context).colorScheme.primary, + foregroundColor: Theme.of(context).colorScheme.onPrimary, + onPressed: () => _onPressed(context), + icon: Icon(icon), + label: Text( + getLabel(context), + overflow: TextOverflow.fade, + ), + ) + : FloatingActionButton( + backgroundColor: Theme.of(context).colorScheme.primary, + foregroundColor: Theme.of(context).colorScheme.onPrimary, + onPressed: () => _onPressed(context), + child: Icon(icon), + ), ); } } diff --git a/lib/pages/settings_chat/settings_chat_view.dart b/lib/pages/settings_chat/settings_chat_view.dart index ed6ea93d..2b381c64 100644 --- a/lib/pages/settings_chat/settings_chat_view.dart +++ b/lib/pages/settings_chat/settings_chat_view.dart @@ -77,12 +77,6 @@ class SettingsChatView extends StatelessWidget { ), ), const Divider(height: 1), - SettingsSwitchListTile.adaptive( - title: L10n.of(context)!.showDirectChatsInSpaces, - onChanged: (b) => AppConfig.showDirectChatsInSpaces = b, - storeKey: SettingKeys.showDirectChatsInSpaces, - defaultValue: AppConfig.showDirectChatsInSpaces, - ), SettingsSwitchListTile.adaptive( title: L10n.of(context)!.separateChatTypes, onChanged: (b) => AppConfig.separateChatTypes = b, diff --git a/lib/utils/matrix_sdk_extensions.dart/client_stories_extension.dart b/lib/utils/matrix_sdk_extensions.dart/client_stories_extension.dart index 269de73c..eff87f23 100644 --- a/lib/utils/matrix_sdk_extensions.dart/client_stories_extension.dart +++ b/lib/utils/matrix_sdk_extensions.dart/client_stories_extension.dart @@ -16,14 +16,8 @@ extension ClientStoriesExtension on Client { room.unsafeGetUserFromMemoryOrFallback(room.directChatMatrixID!)) .toList(); - List get storiesRooms => rooms - .where((room) => - room - .getState(EventTypes.RoomCreate) - ?.content - .tryGet('type') == - storiesRoomType) - .toList(); + List get storiesRooms => + rooms.where((room) => room.isStoryRoom).toList(); Future> getUndecidedContactsForStories(Room? storiesRoom) async { if (storiesRoom == null) return contacts; @@ -96,3 +90,9 @@ extension ClientStoriesExtension on Client { .toList()); } } + +extension StoryRoom on Room { + bool get isStoryRoom => + getState(EventTypes.RoomCreate)?.content.tryGet('type') == + ClientStoriesExtension.storiesRoomType; +} diff --git a/lib/utils/space_navigator.dart b/lib/utils/space_navigator.dart deleted file mode 100644 index af72a0b2..00000000 --- a/lib/utils/space_navigator.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; - -/// this is a workaround to allow navigation of spaces out from any widget. -/// Reason is that we have no reliable way to listen on *query* changes of -/// VRouter. -/// -/// Time wasted: 3h -abstract class SpaceNavigator { - const SpaceNavigator._(); - - // TODO(TheOneWithTheBraid): adjust routing table in order to represent spaces - // ... in any present path - static final routeObserver = RouteObserver(); - - static final StreamController _controller = - StreamController.broadcast(); - - static Stream get stream => _controller.stream; - - static void navigateToSpace(String? spaceId) => _controller.add(spaceId); -} diff --git a/lib/utils/url_launcher.dart b/lib/utils/url_launcher.dart index a3b0a8d4..cc64da35 100644 --- a/lib/utils/url_launcher.dart +++ b/lib/utils/url_launcher.dart @@ -10,7 +10,6 @@ import 'package:url_launcher/url_launcher.dart'; import 'package:vrouter/vrouter.dart'; import 'package:fluffychat/config/app_config.dart'; -import 'package:fluffychat/utils/space_navigator.dart'; import 'package:fluffychat/widgets/matrix.dart'; import 'package:fluffychat/widgets/profile_bottom_sheet.dart'; import 'package:fluffychat/widgets/public_room_bottom_sheet.dart'; @@ -133,7 +132,7 @@ class UrlLauncher { servers.addAll(identityParts.via); if (room != null) { if (room.isSpace) { - SpaceNavigator.navigateToSpace(room.id); + // TODO: Implement navigate to space VRouter.of(context).toSegments(['rooms']); return; } diff --git a/lib/widgets/fluffy_chat_app.dart b/lib/widgets/fluffy_chat_app.dart index e1c6c7be..6bcce179 100644 --- a/lib/widgets/fluffy_chat_app.dart +++ b/lib/widgets/fluffy_chat_app.dart @@ -11,7 +11,6 @@ import 'package:fluffychat/config/routes.dart'; import 'package:fluffychat/config/themes.dart'; import '../config/app_config.dart'; import '../utils/custom_scroll_behaviour.dart'; -import '../utils/space_navigator.dart'; import 'matrix.dart'; class FluffyChatApp extends StatefulWidget { @@ -62,18 +61,14 @@ class FluffyChatAppState extends State { initial: AdaptiveThemeMode.system, builder: (theme, darkTheme) => LayoutBuilder( builder: (context, constraints) { - const maxColumns = 3; - var newColumns = - (constraints.maxWidth / FluffyThemes.columnWidth).floor(); - if (newColumns > maxColumns) newColumns = maxColumns; - columnMode ??= newColumns > 1; - _router ??= GlobalKey(); - if (columnMode != newColumns > 1) { - Logs().v('Set Column Mode = $columnMode'); + final isColumnMode = + FluffyThemes.isColumnModeByWidth(constraints.maxWidth); + if (isColumnMode != columnMode) { + Logs().v('Set Column Mode = $isColumnMode'); WidgetsBinding.instance.addPostFrameCallback((_) { setState(() { _initialUrl = _router?.currentState?.url; - columnMode = newColumns > 1; + columnMode = isColumnMode; _router = GlobalKey(); }); }); @@ -86,9 +81,6 @@ class FluffyChatAppState extends State { logs: kReleaseMode ? VLogs.none : VLogs.info, darkTheme: darkTheme, localizationsDelegates: L10n.localizationsDelegates, - navigatorObservers: [ - SpaceNavigator.routeObserver, - ], supportedLocales: L10n.supportedLocales, initialUrl: _initialUrl ?? '/', routes: AppRoutes(columnMode ?? false).routes, diff --git a/lib/widgets/layouts/two_column_layout.dart b/lib/widgets/layouts/two_column_layout.dart index 9371be3d..3017e851 100644 --- a/lib/widgets/layouts/two_column_layout.dart +++ b/lib/widgets/layouts/two_column_layout.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import '../../config/themes.dart'; + class TwoColumnLayout extends StatelessWidget { final Widget mainView; final Widget sideView; @@ -18,7 +20,8 @@ class TwoColumnLayout extends StatelessWidget { Container( clipBehavior: Clip.antiAlias, decoration: const BoxDecoration(), - width: 360.0, + width: 360.0 + + (FluffyThemes.getDisplayNavigationRail(context) ? 64 : 0), child: mainView, ), Container( diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 3af9da1e..12862df4 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -138,7 +138,7 @@ SPEC CHECKSUMS: flutter_secure_storage_macos: 6ceee8fbc7f484553ad17f79361b556259df89aa flutter_web_auth: ae2c29ca9b98c00b4e0e8c0919bb4a05d44b76df flutter_webrtc: 39478671aae60497438bceafc011357911e00056 - FlutterMacOS: 57701585bf7de1b3fc2bb61f6378d73bbdea8424 + FlutterMacOS: ae6af50a8ea7d6103d888583d46bd8328a7e9811 FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a geolocator_apple: 821be05bbdb1b49500e029ebcbf2d6acf2dfb966 just_audio: 9b67ca7b97c61cfc9784ea23cd8cc55eb226d489