From f6d95b94405585ecd97ac947bb900a27ca603f39 Mon Sep 17 00:00:00 2001 From: TheOneWithTheBraid Date: Tue, 13 Sep 2022 14:01:48 +0200 Subject: [PATCH] feat: add drawer for huge devices Signed-off-by: TheOneWithTheBraid --- assets/l10n/intl_en.arb | 1 + lib/config/setting_keys.dart | 1 + lib/config/themes.dart | 22 ++- lib/pages/chat_list/chat_list.dart | 64 +++++++ lib/pages/chat_list/chat_list_view.dart | 177 +----------------- lib/pages/chat_list/navigation_rail.dart | 82 ++++++++ .../chat_list/navigation_rail_content.dart | 132 +++++++++++++ lib/pages/chat_list/space_view.dart | 124 +++++++++--- lib/pages/chat_list/spaces_drawer.dart | 163 ++++++++++++++++ lib/pages/chat_list/spaces_drawer_entry.dart | 152 +++++++++++++++ lib/widgets/layouts/two_column_layout.dart | 48 ++++- 11 files changed, 760 insertions(+), 206 deletions(-) create mode 100644 lib/pages/chat_list/navigation_rail.dart create mode 100644 lib/pages/chat_list/navigation_rail_content.dart create mode 100644 lib/pages/chat_list/spaces_drawer.dart create mode 100644 lib/pages/chat_list/spaces_drawer_entry.dart diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index 6f7da6d7..4ba68389 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -134,6 +134,7 @@ "senderName": {} } }, + "spaces": "Spaces", "anyoneCanJoin": "Anyone can join", "@anyoneCanJoin": { "type": "text", diff --git a/lib/config/setting_keys.dart b/lib/config/setting_keys.dart index eeb3a16d..fd574297 100644 --- a/lib/config/setting_keys.dart +++ b/lib/config/setting_keys.dart @@ -28,4 +28,5 @@ abstract class SettingKeys { static const String autoplayImages = 'chat.fluffy.autoplay_images'; static const String sendOnEnter = 'chat.fluffy.send_on_enter'; static const String experimentalVoip = 'chat.fluffy.experimental_voip'; + static const String desktopDrawerOpen = 'chat.fluffy.drawer.open'; } diff --git a/lib/config/themes.dart b/lib/config/themes.dart index 3029686e..f01a4052 100644 --- a/lib/config/themes.dart +++ b/lib/config/themes.dart @@ -3,7 +3,9 @@ import 'package:flutter/services.dart'; import 'package:vrouter/vrouter.dart'; +import 'package:fluffychat/config/setting_keys.dart'; import 'package:fluffychat/utils/platform_infos.dart'; +import '../widgets/matrix.dart'; import 'app_config.dart'; abstract class FluffyThemes { @@ -14,8 +16,24 @@ abstract class FluffyThemes { static bool isColumnMode(BuildContext context) => isColumnModeByWidth(MediaQuery.of(context).size.width); - static bool getDisplayNavigationRail(BuildContext context) => - !VRouter.of(context).path.startsWith('/settings'); + static ValueNotifier? _navigationRailWidth; + + static ValueNotifier? getDisplayNavigationRail(BuildContext context) { + if (!VRouter.of(context).path.startsWith('/settings')) { + if (_navigationRailWidth == null) { + _navigationRailWidth = ValueNotifier(false); + Matrix.of(context) + .store + .getItemBool(SettingKeys.desktopDrawerOpen, false) + .then((value) => _navigationRailWidth!.value = value); + } + return _navigationRailWidth; + } else { + return null; + } + } + + static const hugeScreenBreakpoint = 1280; 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 fe09469b..4bb12777 100644 --- a/lib/pages/chat_list/chat_list.dart +++ b/lib/pages/chat_list/chat_list.dart @@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:adaptive_dialog/adaptive_dialog.dart'; +import 'package:badges/badges.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:future_loading_dialog/future_loading_dialog.dart'; import 'package:matrix/matrix.dart'; @@ -13,12 +14,14 @@ import 'package:uni_links/uni_links.dart'; import 'package:vrouter/vrouter.dart'; import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/config/setting_keys.dart'; import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/pages/chat_list/chat_list_view.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/widgets/unread_rooms_badge.dart'; import '../../../utils/account_bundles.dart'; import '../../utils/matrix_sdk_extensions.dart/matrix_file_extension.dart'; import '../../utils/url_launcher.dart'; @@ -667,6 +670,59 @@ class ChatListController extends State }); } + List getNavigationDestinations(BuildContext context) { + final badgePosition = BadgePosition.topEnd(top: -12, end: -8); + return [ + if (AppConfig.separateChatTypes) ...[ + NavigationDestination( + icon: UnreadRoomsBadge( + badgePosition: badgePosition, + filter: getRoomFilterByActiveFilter(ActiveFilter.groups), + child: const Icon(Icons.groups_outlined), + ), + selectedIcon: UnreadRoomsBadge( + badgePosition: badgePosition, + filter: getRoomFilterByActiveFilter(ActiveFilter.groups), + child: const Icon(Icons.groups), + ), + label: L10n.of(context)!.groups, + ), + NavigationDestination( + icon: UnreadRoomsBadge( + badgePosition: badgePosition, + filter: getRoomFilterByActiveFilter(ActiveFilter.messages), + child: const Icon(Icons.chat_outlined), + ), + selectedIcon: UnreadRoomsBadge( + badgePosition: badgePosition, + filter: getRoomFilterByActiveFilter(ActiveFilter.messages), + child: const Icon(Icons.chat), + ), + label: L10n.of(context)!.messages, + ), + ] else + NavigationDestination( + icon: UnreadRoomsBadge( + badgePosition: badgePosition, + filter: getRoomFilterByActiveFilter(ActiveFilter.allChats), + child: const Icon(Icons.chat_outlined), + ), + selectedIcon: UnreadRoomsBadge( + badgePosition: badgePosition, + filter: getRoomFilterByActiveFilter(ActiveFilter.allChats), + child: const Icon(Icons.chat), + ), + label: L10n.of(context)!.chats, + ), + if (spaces.isNotEmpty) + NavigationDestination( + icon: const Icon(Icons.workspaces_outlined), + selectedIcon: const Icon(Icons.workspaces), + label: L10n.of(context)!.spaces, + ), + ]; + } + @override Widget build(BuildContext context) { Matrix.of(context).navigatorContext = context; @@ -685,6 +741,14 @@ class ChatListController extends State Future dehydrate() => SettingsAccountController.dehydrateDevice(context); + + void toggleDesktopDrawer() { + final listenable = FluffyThemes.getDisplayNavigationRail(context)!; + listenable.value = !listenable.value; + Matrix.of(context) + .store + .setItemBool(SettingKeys.desktopDrawerOpen, listenable.value); + } } enum EditBundleAction { addToBundle, removeFromBundle } diff --git a/lib/pages/chat_list/chat_list_view.dart b/lib/pages/chat_list/chat_list_view.dart index 99ee5c9e..f0162506 100644 --- a/lib/pages/chat_list/chat_list_view.dart +++ b/lib/pages/chat_list/chat_list_view.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:badges/badges.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:keyboard_shortcuts/keyboard_shortcuts.dart'; import 'package:vrouter/vrouter.dart'; @@ -9,11 +8,10 @@ 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/widgets/avatar.dart'; -import 'package:fluffychat/widgets/unread_rooms_badge.dart'; import '../../widgets/matrix.dart'; import 'chat_list_body.dart'; import 'chat_list_header.dart'; +import 'navigation_rail.dart'; import 'start_chat_fab.dart'; class ChatListView extends StatelessWidget { @@ -21,66 +19,8 @@ class ChatListView extends StatelessWidget { const ChatListView(this.controller, {Key? key}) : super(key: key); - List getNavigationDestinations(BuildContext context) { - final badgePosition = BadgePosition.topEnd(top: -12, end: -8); - return [ - if (AppConfig.separateChatTypes) ...[ - NavigationDestination( - icon: UnreadRoomsBadge( - badgePosition: badgePosition, - filter: controller.getRoomFilterByActiveFilter(ActiveFilter.groups), - child: const Icon(Icons.groups_outlined), - ), - selectedIcon: UnreadRoomsBadge( - badgePosition: badgePosition, - filter: controller.getRoomFilterByActiveFilter(ActiveFilter.groups), - child: const Icon(Icons.groups), - ), - label: L10n.of(context)!.groups, - ), - NavigationDestination( - icon: UnreadRoomsBadge( - badgePosition: badgePosition, - filter: - controller.getRoomFilterByActiveFilter(ActiveFilter.messages), - child: const Icon(Icons.chat_outlined), - ), - selectedIcon: UnreadRoomsBadge( - badgePosition: badgePosition, - filter: - controller.getRoomFilterByActiveFilter(ActiveFilter.messages), - child: const Icon(Icons.chat), - ), - label: L10n.of(context)!.messages, - ), - ] else - NavigationDestination( - icon: UnreadRoomsBadge( - badgePosition: badgePosition, - filter: - controller.getRoomFilterByActiveFilter(ActiveFilter.allChats), - child: const Icon(Icons.chat_outlined), - ), - selectedIcon: UnreadRoomsBadge( - badgePosition: badgePosition, - filter: - controller.getRoomFilterByActiveFilter(ActiveFilter.allChats), - child: 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) { - final client = Matrix.of(context).client; return StreamBuilder( stream: Matrix.of(context).onShareContentChanged.stream, builder: (_, __) { @@ -106,116 +46,8 @@ class ChatListView extends StatelessWidget { child: Row( children: [ if (FluffyThemes.isColumnMode(context) && - FluffyThemes.getDisplayNavigationRail(context)) ...[ - Builder(builder: (context) { - 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); - - return SizedBox( - width: 64, - child: ListView.builder( - scrollDirection: Axis.vertical, - itemCount: rootSpaces.length + destinations.length, - itemBuilder: (context, i) { - if (i < destinations.length) { - final isSelected = i == controller.selectedIndex; - return Container( - height: 64, - width: 64, - decoration: BoxDecoration( - border: Border( - bottom: i == (destinations.length - 1) - ? BorderSide( - width: 1, - color: Theme.of(context).dividerColor, - ) - : BorderSide.none, - 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.secondary - : null, - icon: CircleAvatar( - backgroundColor: isSelected - ? Theme.of(context).colorScheme.secondary - : Theme.of(context) - .colorScheme - .background, - foregroundColor: isSelected - ? Theme.of(context) - .colorScheme - .onSecondary - : Theme.of(context) - .colorScheme - .onBackground, - 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( - border: Border( - left: BorderSide( - color: isSelected - ? Theme.of(context).colorScheme.secondary - : 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), - ), - ); - }, - ), - ); - }), + FluffyThemes.getDisplayNavigationRail(context) != null) ...[ + ChatListNavigationRail(controller: controller), Container( color: Theme.of(context).dividerColor, width: 1, @@ -235,7 +67,8 @@ class ChatListView extends StatelessWidget { selectedIndex: controller.selectedIndex, onDestinationSelected: controller.onDestinationSelected, - destinations: getNavigationDestinations(context), + destinations: + controller.getNavigationDestinations(context), ) : null, floatingActionButtonLocation: diff --git a/lib/pages/chat_list/navigation_rail.dart b/lib/pages/chat_list/navigation_rail.dart new file mode 100644 index 00000000..330aff1d --- /dev/null +++ b/lib/pages/chat_list/navigation_rail.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; + +import 'package:animations/animations.dart'; + +import 'package:fluffychat/config/themes.dart'; +import 'package:fluffychat/pages/chat_list/chat_list.dart'; +import 'package:fluffychat/widgets/matrix.dart'; +import 'navigation_rail_content.dart'; +import 'spaces_drawer.dart'; + +final drawerKey = GlobalKey>(); + +class ChatListNavigationRail extends StatelessWidget { + final ChatListController controller; + + const ChatListNavigationRail({Key? key, required this.controller}) + : super(key: key); + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: FluffyThemes.getDisplayNavigationRail(context)!, + builder: (context, drawerOpen, c) { + final client = Matrix.of(context).client; + final allSpaces = client.rooms.where((room) => room.isSpace); + final rootSpaces = allSpaces + .where( + (space) => space.hasNotJoinedParentSpace(), + ) + .toList(); + final destinations = controller.getNavigationDestinations(context); + + return LayoutBuilder( + builder: (context, constraints) { + final allowFullSizeDrawer = MediaQuery.of(context).size.width >= + FluffyThemes.hugeScreenBreakpoint; + drawerOpen &= allowFullSizeDrawer; + return AnimatedContainer( + key: drawerKey, + width: drawerOpen ? 256 : 64, + duration: const Duration(milliseconds: 250), + curve: Curves.easeOut, + child: PageTransitionSwitcher( + reverse: drawerOpen, + transitionBuilder: ( + Widget child, + Animation primaryAnimation, + Animation secondaryAnimation, + ) { + return SharedAxisTransition( + animation: primaryAnimation, + secondaryAnimation: secondaryAnimation, + transitionType: SharedAxisTransitionType.horizontal, + fillColor: Theme.of(context).scaffoldBackgroundColor, + child: child, + ); + }, + layoutBuilder: (children) => Stack( + alignment: Alignment.topLeft, + children: children, + ), + child: drawerOpen + ? SpacesDrawer( + key: const ValueKey(true), + rootSpaces: rootSpaces, + destinations: destinations, + controller: controller) + : NavigationRailContent( + key: const ValueKey(false), + allowFullSizeDrawer: allowFullSizeDrawer, + rootSpaces: rootSpaces, + destinations: destinations, + controller: controller, + ), + ), + ); + }, + ); + }, + ); + } +} diff --git a/lib/pages/chat_list/navigation_rail_content.dart b/lib/pages/chat_list/navigation_rail_content.dart new file mode 100644 index 00000000..8d571c9e --- /dev/null +++ b/lib/pages/chat_list/navigation_rail_content.dart @@ -0,0 +1,132 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/pages/chat_list/chat_list.dart'; +import 'package:fluffychat/widgets/avatar.dart'; + +class NavigationRailContent extends StatelessWidget { + final List rootSpaces; + final List destinations; + final ChatListController controller; + final bool allowFullSizeDrawer; + + const NavigationRailContent({ + Key? key, + required this.rootSpaces, + required this.destinations, + required this.controller, + required this.allowFullSizeDrawer, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final count = rootSpaces.length + destinations.length; + final listView = ListView.builder( + scrollDirection: Axis.vertical, + itemCount: count, + itemBuilder: (context, i) { + if (i < destinations.length) { + final isSelected = i == controller.selectedIndex; + return Container( + height: 64, + width: 64, + decoration: BoxDecoration( + border: Border( + bottom: i == (destinations.length - 1) + ? BorderSide( + width: 1, + color: Theme.of(context).dividerColor, + ) + : BorderSide.none, + 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.secondary : null, + icon: CircleAvatar( + backgroundColor: isSelected + ? Theme.of(context).colorScheme.secondary + : Theme.of(context).colorScheme.background, + foregroundColor: isSelected + ? Theme.of(context).colorScheme.onSecondary + : Theme.of(context).colorScheme.onBackground, + 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( + border: Border( + left: BorderSide( + color: isSelected + ? Theme.of(context).colorScheme.secondary + : 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), + ), + ); + }, + ); + if (allowFullSizeDrawer) { + return Column( + children: [ + Expanded(child: listView), + Container( + height: 64, + width: 64, + alignment: Alignment.center, + child: CircleAvatar( + backgroundColor: Theme.of(context).colorScheme.secondary, + child: IconButton( + color: Theme.of(context).colorScheme.onSecondary, + tooltip: L10n.of(context)!.allSpaces, + onPressed: controller.toggleDesktopDrawer, + icon: const Icon(Icons.arrow_right), + ), + ), + ), + ], + ); + } else { + return listView; + } + } +} diff --git a/lib/pages/chat_list/space_view.dart b/lib/pages/chat_list/space_view.dart index 3885503a..98448951 100644 --- a/lib/pages/chat_list/space_view.dart +++ b/lib/pages/chat_list/space_view.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:adaptive_dialog/adaptive_dialog.dart'; @@ -17,6 +19,7 @@ import '../../widgets/matrix.dart'; class SpaceView extends StatefulWidget { final ChatListController controller; final ScrollController scrollController; + const SpaceView( this.controller, { Key? key, @@ -28,23 +31,10 @@ class SpaceView extends StatefulWidget { } class _SpaceViewState extends State { - static final Map> _requests = {}; + SpaceHierarchyCache? cache; String? prevBatch; - void _refresh() { - setState(() { - _requests.remove(widget.controller.activeSpaceId); - }); - } - - Future getFuture(String activeSpaceId) => - _requests[activeSpaceId] ??= Matrix.of(context).client.getSpaceHierarchy( - activeSpaceId, - maxDepth: 1, - from: prevBatch, - ); - void _onJoinSpaceChild(SpaceRoomsChunk spaceChild) async { final client = Matrix.of(context).client; final space = client.getRoomById(widget.controller.activeSpaceId!); @@ -64,7 +54,9 @@ class _SpaceViewState extends State { }, ); if (result.error != null) return; - _refresh(); + setState(() { + cache!.refresh(widget.controller.activeSpaceId!); + }); } if (spaceChild.roomType == 'm.space') { if (spaceChild.roomId == widget.controller.activeSpaceId) { @@ -132,6 +124,8 @@ class _SpaceViewState extends State { @override Widget build(BuildContext context) { + cache ??= SpaceHierarchyCache.instance ??= + SpaceHierarchyCache(client: Matrix.of(context).client); final client = Matrix.of(context).client; final activeSpaceId = widget.controller.activeSpaceId; final allSpaces = client.rooms.where((room) => room.isSpace); @@ -170,7 +164,7 @@ class _SpaceViewState extends State { ); } return FutureBuilder( - future: getFuture(activeSpaceId), + future: cache!.getFuture(activeSpaceId, prevBatch), builder: (context, snapshot) { final response = snapshot.data; final error = snapshot.error; @@ -184,7 +178,7 @@ class _SpaceViewState extends State { child: Text(error.toLocalizedString(context)), ), IconButton( - onPressed: _refresh, + onPressed: () => cache!.refresh(activeSpaceId), icon: const Icon(Icons.refresh_outlined), ) ], @@ -221,24 +215,38 @@ class _SpaceViewState extends State { : parentSpace.displayname), trailing: IconButton( icon: snapshot.connectionState != ConnectionState.done - ? const CircularProgressIndicator.adaptive() + ? const SizedBox.square( + dimension: 24, + child: CircularProgressIndicator.adaptive(), + ) : const Icon(Icons.refresh_outlined), onPressed: snapshot.connectionState != ConnectionState.done ? null - : _refresh, + : () => setState(() { + cache!.refresh(activeSpaceId); + }), ), ); } i--; if (canLoadMore && i == spaceChildren.length) { return ListTile( - title: Text(L10n.of(context)!.loadMore), - trailing: const Icon(Icons.chevron_right_outlined), - onTap: () { - prevBatch = response.nextBatch; - _refresh(); - }, + title: TextButton.icon( + label: Text(L10n.of(context)!.loadMore), + icon: cache?.isRefreshing(activeSpaceId) ?? false + ? const SizedBox.square( + dimension: 24, + child: CircularProgressIndicator(), + ) + : const Icon(Icons.refresh), + onPressed: () { + prevBatch = snapshot.data!.nextBatch!; + setState(() { + cache!.refresh(activeSpaceId); + }); + }, + ), ); } final spaceChild = spaceChildren[i]; @@ -336,3 +344,69 @@ enum SpaceChildContextAction { leave, removeFromSpace, } + +class SpaceHierarchyCache { + static SpaceHierarchyCache? instance; + + final Client client; + final Map>> _requests = {}; + + SpaceHierarchyCache({required this.client}); + + void refresh(String activeSpaceId) { + _requests.remove(activeSpaceId); + } + + bool isRefreshing(String spaceId) => + _requests[spaceId] != null && + (_requests[spaceId]?.any((element) => !element.isCompleted) ?? false); + + Future getFuture( + String activeSpaceId, + String? prevBatch, + ) { + _requests[activeSpaceId] ??= []; + final completer = Completer(); + client + .getSpaceHierarchy( + activeSpaceId, + maxDepth: 1, + from: prevBatch, + ) + .then( + completer.complete, + ) + .onError(completer.completeError); + _requests[activeSpaceId]?.add(completer); + + return Future.wait(_requests[activeSpaceId]!.reversed.map( + (e) => e.future.onError((e, s) => null), + )).then( + (value) => SpacesHierarchyMerges.merged( + value.whereNotNull().toList(), + ), + ); + } +} + +extension SpacesHierarchyMerges on GetSpaceHierarchyResponse { + static GetSpaceHierarchyResponse merged( + List responses, + ) { + final rooms = []; + for (final response in responses) { + for (final newRoom in response.rooms) { + if (rooms.none( + (existingRoom) => existingRoom.roomId == newRoom.roomId, + )) { + rooms.add(newRoom); + } + } + } + String? nextBatch; + if (!responses.any((response) => response.nextBatch == null)) { + nextBatch = responses.last.nextBatch; + } + return GetSpaceHierarchyResponse(rooms: rooms, nextBatch: nextBatch); + } +} diff --git a/lib/pages/chat_list/spaces_drawer.dart b/lib/pages/chat_list/spaces_drawer.dart new file mode 100644 index 00000000..e0addee0 --- /dev/null +++ b/lib/pages/chat_list/spaces_drawer.dart @@ -0,0 +1,163 @@ +import 'package:flutter/material.dart'; + +import 'package:collection/collection.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:matrix/matrix.dart'; + +import 'chat_list.dart'; +import 'spaces_drawer_entry.dart'; + +class SpacesDrawer extends StatelessWidget { + final ChatListController controller; + final List rootSpaces; + final List destinations; + + const SpacesDrawer({ + Key? key, + required this.controller, + required this.rootSpaces, + required this.destinations, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final spacesHierarchy = controller.spaces + .map((e) => + SpacesEntryMaybeChildren.buildIfTopLevel(e, controller.spaces)) + .whereNotNull() + .toList(); + + final filteredDestinations = destinations; + filteredDestinations + .removeWhere((element) => element.label == L10n.of(context)!.spaces); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: ListView.builder( + itemCount: spacesHierarchy.length + filteredDestinations.length, + itemBuilder: (context, i) { + if (i < filteredDestinations.length) { + final isSelected = i == controller.selectedIndex; + return Container( + height: 64, + decoration: BoxDecoration( + border: Border( + bottom: i == (filteredDestinations.length - 1) + ? BorderSide( + width: 1, + color: Theme.of(context).dividerColor, + ) + : BorderSide.none, + 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: ListTile( + leading: CircleAvatar( + backgroundColor: isSelected + ? Theme.of(context).colorScheme.secondary + : Theme.of(context).colorScheme.background, + foregroundColor: isSelected + ? Theme.of(context).colorScheme.onSecondary + : Theme.of(context).colorScheme.onBackground, + child: i == controller.selectedIndex + ? filteredDestinations[i].selectedIcon ?? + filteredDestinations[i].icon + : filteredDestinations[i].icon), + title: Text(filteredDestinations[i].label), + onTap: () => controller.onDestinationSelected(i), + ), + ); + } else { + i -= filteredDestinations.length; + } + final space = spacesHierarchy[i]; + return SpacesDrawerEntry( + entry: space, + controller: controller, + ); + }, + ), + ), + Container( + height: 64, + width: 64, + alignment: Alignment.centerLeft, + child: Padding( + padding: const EdgeInsets.all(12.0), + child: CircleAvatar( + backgroundColor: Theme.of(context).colorScheme.secondary, + foregroundColor: Theme.of(context).colorScheme.onSecondary, + child: IconButton( + tooltip: L10n.of(context)!.allSpaces, + color: Theme.of(context).colorScheme.onSecondary, + onPressed: controller.toggleDesktopDrawer, + icon: const Icon(Icons.arrow_left), + ), + ), + ), + ), + ], + ); + } +} + +class SpacesEntryMaybeChildren { + final Room spacesEntry; + + final Set children; + + const SpacesEntryMaybeChildren(this.spacesEntry, [this.children = const {}]); + + static SpacesEntryMaybeChildren? buildIfTopLevel( + Room room, List allEntries, + [String? parent]) { + // 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.spaceParents.any((parent) => parent.roomId == room.id)) + .toList(); + return SpacesEntryMaybeChildren( + room, + children + .map((e) => buildIfTopLevel(e, allEntries, room.id)) + .whereNotNull() + .toSet()); + } + } + + bool isActiveOrChild(ChatListController controller) => + spacesEntry.id == controller.activeSpaceId || + children.any( + (element) => element.isActiveOrChild(controller), + ); +} + +extension HasNotJoinedParentSpace on Room { + bool hasNotJoinedParentSpace() { + return !client.rooms.any( + (parentSpace) => + parentSpace.isSpace && + parentSpace.spaceChildren.any((child) => child.roomId == id), + ); + } +} diff --git a/lib/pages/chat_list/spaces_drawer_entry.dart b/lib/pages/chat_list/spaces_drawer_entry.dart new file mode 100644 index 00000000..f936ac79 --- /dev/null +++ b/lib/pages/chat_list/spaces_drawer_entry.dart @@ -0,0 +1,152 @@ +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:fluffychat/pages/chat_list/chat_list.dart'; +import 'package:fluffychat/pages/chat_list/space_view.dart'; +import 'package:fluffychat/pages/chat_list/spaces_drawer.dart'; +import 'package:fluffychat/widgets/avatar.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +class SpacesDrawerEntry extends StatefulWidget { + final SpacesEntryMaybeChildren entry; + final ChatListController controller; + + const SpacesDrawerEntry( + {Key? key, required this.entry, required this.controller}) + : super(key: key); + + @override + State createState() => _SpacesDrawerEntryState(); +} + +class _SpacesDrawerEntryState extends State { + SpaceHierarchyCache? _cache; + + String? prevBatch; + + @override + Widget build(BuildContext context) { + _cache ??= SpaceHierarchyCache.instance ??= + SpaceHierarchyCache(client: Matrix.of(context).client); + return FutureBuilder( + future: _cache!.getFuture(widget.entry.spacesEntry.id, prevBatch), + builder: (context, snapshot) { + final space = Matrix.of(context).client.rooms.singleWhereOrNull( + (element) => + element.id == snapshot.data?.rooms.first.roomId) ?? + widget.entry.spacesEntry; + final room = space; + + final canLoadMore = snapshot.data?.nextBatch != null; + final active = + widget.controller.activeSpaceId == widget.entry.spacesEntry.id; + final leading = Container( + width: 48, + height: 48, + alignment: Alignment.center, + child: Avatar( + mxContent: space.avatar, + name: space.displayname, + size: 32, + fontSize: 12, + ), + ); + final title = Text( + space.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ); + void onTap() { + widget.controller.setActiveSpace(space.id); + } + + final trailing = SizedBox( + width: 32, + child: IconButton( + splashRadius: 24, + icon: const Icon(Icons.edit_outlined), + tooltip: L10n.of(context)!.edit, + onPressed: () => widget.controller.editSpace(context, room.id), + ), + ); + + if (widget.entry.children.isEmpty && !canLoadMore) { + return ListTile( + selected: active, + leading: leading, + title: title, + onTap: onTap, + trailing: trailing, + ); + } else { + final isSelected = + widget.controller.activeFilter == ActiveFilter.spaces && + space.id == widget.controller.activeSpaceId; + return Stack( + alignment: Alignment.topLeft, + children: [ + ExpansionTile( + leading: leading, + initiallyExpanded: widget.entry.children.any((element) => + widget.entry.isActiveOrChild(widget.controller)), + title: GestureDetector( + onTap: onTap, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.max, + children: [ + Expanded(child: title), + const SizedBox(width: 8), + trailing + ]), + ), + children: [ + ...widget.entry.children.map((e) => SpacesDrawerEntry( + entry: e, controller: widget.controller)), + if (canLoadMore) + ListTile( + title: TextButton.icon( + label: Text(L10n.of(context)!.loadMore), + icon: _cache?.isRefreshing( + widget.entry.spacesEntry.id, + ) ?? + false + ? const SizedBox.square( + dimension: 24, + child: CircularProgressIndicator(), + ) + : const Icon(Icons.refresh), + onPressed: () { + prevBatch = snapshot.data!.nextBatch!; + setState(() { + _cache!.refresh(widget.entry.spacesEntry.id); + }); + }, + ), + ), + ], + ), + Container( + height: 56, + width: 4, + decoration: BoxDecoration( + border: Border( + left: BorderSide( + color: isSelected + ? Theme.of(context).colorScheme.secondary + : Colors.transparent, + width: 4, + ), + ), + ), + alignment: Alignment.center, + ), + ], + ); + } + }); + } +} diff --git a/lib/widgets/layouts/two_column_layout.dart b/lib/widgets/layouts/two_column_layout.dart index 3017e851..27908513 100644 --- a/lib/widgets/layouts/two_column_layout.dart +++ b/lib/widgets/layouts/two_column_layout.dart @@ -11,19 +11,33 @@ class TwoColumnLayout extends StatelessWidget { required this.mainView, required this.sideView, }) : super(key: key); + @override Widget build(BuildContext context) { + final listenable = FluffyThemes.getDisplayNavigationRail(context); return ScaffoldMessenger( child: Scaffold( body: Row( children: [ - Container( - clipBehavior: Clip.antiAlias, - decoration: const BoxDecoration(), - width: 360.0 + - (FluffyThemes.getDisplayNavigationRail(context) ? 64 : 0), - child: mainView, - ), + // otherwise, we'd have an ugly animation where we don't want any... + listenable == null + ? _FirstColumnChild(child: mainView) + : ValueListenableBuilder( + valueListenable: listenable, + child: mainView, + builder: (context, open, child) => + LayoutBuilder(builder: (context, constraints) { + final width = open && + MediaQuery.of(context).size.width > + FluffyThemes.hugeScreenBreakpoint + ? 256.0 + : 64.0; + return _FirstColumnChild( + drawerWidth: width, + child: child, + ); + }), + ), Container( width: 1.0, color: Theme.of(context).dividerColor, @@ -39,3 +53,23 @@ class TwoColumnLayout extends StatelessWidget { ); } } + +class _FirstColumnChild extends StatelessWidget { + final Widget? child; + final double drawerWidth; + + const _FirstColumnChild({Key? key, required this.child, this.drawerWidth = 0}) + : super(key: key); + + @override + Widget build(BuildContext context) { + return AnimatedContainer( + clipBehavior: Clip.antiAlias, + decoration: const BoxDecoration(), + width: 360.0 + drawerWidth, + duration: const Duration(milliseconds: 250), + curve: Curves.easeOut, + child: child, + ); + } +}