diff --git a/commet/lib/client/client_manager.dart b/commet/lib/client/client_manager.dart index 454ebd719..618b718cd 100644 --- a/commet/lib/client/client_manager.dart +++ b/commet/lib/client/client_manager.dart @@ -9,6 +9,7 @@ import 'package:commet/client/matrix/matrix_client.dart'; import 'package:commet/client/stale_info.dart'; import 'package:commet/client/tasks/client_connection_status_task.dart'; import 'package:commet/main.dart'; +import 'package:commet/client/sidebar/sidebar_manager.dart'; import 'package:commet/utils/notifying_list.dart'; class ClientManager { @@ -22,10 +23,12 @@ class ClientManager { late CallManager callManager; late final DirectMessagesAggregator directMessages; + late final SidebarManager sidebarManager; ClientManager() { directMessages = DirectMessagesAggregator(this); callManager = CallManager(this); + sidebarManager = SidebarManager(this); } List get rooms => _rooms; @@ -99,6 +102,8 @@ class ClientManager { isBackgroundService: isBackgroundService), ]); + newClientManager.sidebarManager.init(); + return newClientManager; } @@ -229,6 +234,7 @@ class ClientManager { } Future close() async { + sidebarManager.dispose(); for (var client in _clients.values) { client.close(); } diff --git a/commet/lib/client/sidebar/resolved_sidebar_item.dart b/commet/lib/client/sidebar/resolved_sidebar_item.dart new file mode 100644 index 000000000..8a197f023 --- /dev/null +++ b/commet/lib/client/sidebar/resolved_sidebar_item.dart @@ -0,0 +1,24 @@ +import 'package:commet/client/client.dart'; + +sealed class ResolvedSidebarItem { + const ResolvedSidebarItem(); +} + +class ResolvedSpace extends ResolvedSidebarItem { + final Space space; + const ResolvedSpace(this.space); +} + +class ResolvedFolder extends ResolvedSidebarItem { + final String id; + final String name; + final List spaces; + final bool isExpanded; + + const ResolvedFolder({ + required this.id, + required this.name, + required this.spaces, + this.isExpanded = false, + }); +} diff --git a/commet/lib/client/sidebar/sidebar_data.dart b/commet/lib/client/sidebar/sidebar_data.dart new file mode 100644 index 000000000..4a78e572d --- /dev/null +++ b/commet/lib/client/sidebar/sidebar_data.dart @@ -0,0 +1,61 @@ +import 'package:commet/client/sidebar/sidebar_model.dart'; + +class SidebarData { + static const String accountDataType = 'im.commet.space_sidebar'; + static const int currentVersion = 1; + + final int version; + final List items; + + const SidebarData({this.version = currentVersion, this.items = const []}); + + factory SidebarData.fromJson(Map json) { + final version = json['version'] as int? ?? currentVersion; + final rawItems = json['items'] as List? ?? []; + + final items = []; + for (final raw in rawItems) { + if (raw is! Map) continue; + + final type = raw['type'] as String?; + switch (type) { + case 'space': + final id = raw['id'] as String?; + if (id != null) items.add(SidebarSpace(id)); + break; + case 'folder': + final id = raw['id'] as String?; + final name = raw['name'] as String? ?? ''; + final children = (raw['children'] as List?) + ?.whereType() + .toList() ?? + []; + if (id != null && children.isNotEmpty) { + items.add(SidebarFolder(id: id, name: name, children: children)); + } + break; + } + } + + return SidebarData(version: version, items: items); + } + + Map toJson() { + return { + 'version': version, + 'items': items.map((item) { + return switch (item) { + SidebarSpace s => {'type': 'space', 'id': s.spaceId}, + SidebarFolder f => { + 'type': 'folder', + 'id': f.id, + 'name': f.name, + 'children': f.children, + }, + }; + }).toList(), + }; + } + + static SidebarData empty() => const SidebarData(); +} diff --git a/commet/lib/client/sidebar/sidebar_manager.dart b/commet/lib/client/sidebar/sidebar_manager.dart new file mode 100644 index 000000000..24c94b0e9 --- /dev/null +++ b/commet/lib/client/sidebar/sidebar_manager.dart @@ -0,0 +1,403 @@ +import 'dart:async'; + +import 'package:commet/client/client.dart'; +import 'package:commet/client/client_manager.dart'; +import 'package:commet/client/sidebar/resolved_sidebar_item.dart'; +import 'package:commet/client/sidebar/sidebar_data.dart'; +import 'package:commet/client/sidebar/sidebar_model.dart'; +import 'package:commet/client/sidebar/sidebar_persistence.dart'; +import 'package:commet/utils/debounce.dart'; +import 'package:commet/utils/event_bus.dart'; +import 'package:uuid/uuid.dart'; + +class SidebarManager { + final ClientManager clientManager; + + List _rawItems = []; + List _resolvedItems = []; + final Set _expandedFolders = {}; + Client? _filterClient; + final Debouncer _persistDebouncer = + Debouncer(delay: const Duration(seconds: 2)); + + final List _subscriptions = []; + + List get items => _resolvedItems; + + final StreamController onSidebarChanged = StreamController.broadcast(); + + SidebarManager(this.clientManager); + + void init() { + _loadFromAccountData(); + _resolve(); + + _subscriptions.addAll([ + clientManager.onSpaceAdded.listen((_) => _resolve()), + clientManager.onSpaceRemoved.listen((_) => _resolve()), + clientManager.onSync.stream.listen((_) => _checkAccountDataChanged()), + clientManager.onClientAdded.stream.listen((_) { + _loadFromAccountData(); + _resolve(); + }), + clientManager.onClientRemoved.stream.listen((_) => _resolve()), + EventBus.setFilterClient.stream.listen((client) { + _filterClient = client; + _resolve(); + }), + ]); + } + + void dispose() { + for (var sub in _subscriptions) { + sub.cancel(); + } + _persistDebouncer.cancel(); + onSidebarChanged.close(); + } + + void _loadFromAccountData() { + SidebarData? best; + int bestCount = -1; + + for (var client in clientManager.clients) { + var data = SidebarPersistence.readFromClient(client); + if (data.items.length > bestCount) { + best = data; + bestCount = data.items.length; + } + } + + _rawItems = List.from(best?.items ?? []); + } + + String? _lastAccountDataHash; + + void _checkAccountDataChanged() { + for (var client in clientManager.clients) { + var data = SidebarPersistence.readFromClient(client); + var hash = data.toJson().toString(); + if (_lastAccountDataHash == null) { + _lastAccountDataHash = hash; + return; + } + if (hash != _lastAccountDataHash) { + _lastAccountDataHash = hash; + _rawItems = List.from(data.items); + _resolve(); + return; + } + } + } + + void _resolve() { + final topLevelSpaces = clientManager.spaces + .where((s) => s.isTopLevel) + .where((s) => _filterClient == null || s.client == _filterClient) + .toList(); + + final spaceMap = {}; + for (var space in topLevelSpaces) { + spaceMap.putIfAbsent(space.identifier, () => space); + } + + final resolved = []; + final placedIds = {}; + + for (var item in _rawItems) { + switch (item) { + case SidebarSpace s: + final space = spaceMap[s.spaceId]; + if (space != null) { + resolved.add(ResolvedSpace(space)); + placedIds.add(s.spaceId); + } + break; + case SidebarFolder f: + final folderSpaces = []; + for (var childId in f.children) { + final space = spaceMap[childId]; + if (space != null) folderSpaces.add(space); + placedIds.add(childId); + } + + if (!folderSpaces.isEmpty) { + if (folderSpaces.length == 1) { + resolved.add(ResolvedSpace(folderSpaces.first)); + } else { + resolved.add(ResolvedFolder( + id: f.id, + name: f.name, + spaces: folderSpaces, + isExpanded: _expandedFolders.contains(f.id), + )); + } + } + break; + } + } + + final unplaced = + topLevelSpaces.where((s) => !placedIds.contains(s.identifier)).toList(); + + resolved.insertAll(0, unplaced.reversed.map((s) => ResolvedSpace(s))); + + _resolvedItems = resolved; + onSidebarChanged.add(null); + } + + void _debouncedPersist() { + _persistDebouncer.run(_persist); + } + + Future _persist() async { + final data = SidebarData(items: _rawItems); + _lastAccountDataHash = data.toJson().toString(); + await SidebarPersistence.writeToAllClients(clientManager.clients, data); + } + + void reorder(int oldIndex, int newIndex) { + final rawOld = _resolvedToRawIndex(oldIndex); + final rawNew = _resolvedToRawIndex(newIndex); + + if (rawOld == null) { + final item = _resolvedItems[oldIndex]; + if (item is! ResolvedSpace) return; + final spaceItem = SidebarSpace(item.space.identifier); + + if (rawNew != null) { + _rawItems.insert(rawNew, spaceItem); + } else { + _rawItems.add(spaceItem); + } + } else if (rawNew == null) { + final item = _rawItems.removeAt(rawOld); + _rawItems.insert(0, item); + } else { + final adjustedNew = rawNew > rawOld ? rawNew - 1 : rawNew; + final item = _rawItems.removeAt(rawOld); + _rawItems.insert(adjustedNew, item); + } + + _resolve(); + _debouncedPersist(); + } + + int? _resolvedToRawIndex(int resolvedIndex) { + if (resolvedIndex < 0 || resolvedIndex >= _resolvedItems.length) { + return _rawItems.length; + } + final resolved = _resolvedItems[resolvedIndex]; + switch (resolved) { + case ResolvedSpace s: + for (int i = 0; i < _rawItems.length; i++) { + final raw = _rawItems[i]; + if (raw is SidebarSpace && raw.spaceId == s.space.identifier) { + return i; + } + } + return null; + case ResolvedFolder f: + for (int i = 0; i < _rawItems.length; i++) { + final raw = _rawItems[i]; + if (raw is SidebarFolder && raw.id == f.id) return i; + } + return null; + } + } + + void createFolder( + String name, String spaceIdA, String spaceIdB, int insertIndex) { + final folderId = const Uuid().v4(); + + _rawItems.removeWhere((item) => + item is SidebarSpace && + (item.spaceId == spaceIdA || item.spaceId == spaceIdB)); + + _removeSpaceFromAnyFolder(spaceIdA); + _removeSpaceFromAnyFolder(spaceIdB); + + final folder = SidebarFolder( + id: folderId, + name: name, + children: [spaceIdA, spaceIdB], + ); + + final clampedIndex = insertIndex.clamp(0, _rawItems.length); + _rawItems.insert(clampedIndex, folder); + + _resolve(); + _debouncedPersist(); + } + + void addSpaceToFolder(String folderId, String spaceId) { + _rawItems + .removeWhere((item) => item is SidebarSpace && item.spaceId == spaceId); + + _removeSpaceFromAnyFolder(spaceId, exceptFolderId: folderId); + + for (int i = 0; i < _rawItems.length; i++) { + final item = _rawItems[i]; + if (item is SidebarFolder && item.id == folderId) { + if (!item.children.contains(spaceId)) { + _rawItems[i] = item.copyWith(children: [...item.children, spaceId]); + } + break; + } + } + + _resolve(); + _debouncedPersist(); + } + + void addSpaceToFolderAt(String folderId, String spaceId, int index) { + _rawItems + .removeWhere((item) => item is SidebarSpace && item.spaceId == spaceId); + + _removeSpaceFromAnyFolder(spaceId, exceptFolderId: folderId); + + for (int i = 0; i < _rawItems.length; i++) { + final item = _rawItems[i]; + if (item is SidebarFolder && item.id == folderId) { + final children = List.from(item.children); + children.remove(spaceId); + final clampedIndex = index.clamp(0, children.length); + children.insert(clampedIndex, spaceId); + _rawItems[i] = item.copyWith(children: children); + break; + } + } + + _resolve(); + _debouncedPersist(); + } + + void _removeSpaceFromAnyFolder(String spaceId, {String? exceptFolderId}) { + for (int i = 0; i < _rawItems.length; i++) { + final item = _rawItems[i]; + if (item is SidebarFolder && + item.id != exceptFolderId && + item.children.contains(spaceId)) { + final newChildren = item.children.where((c) => c != spaceId).toList(); + if (newChildren.length <= 1) { + _rawItems.removeAt(i); + for (var child in newChildren) { + _rawItems.insert(i, SidebarSpace(child)); + } + } else { + _rawItems[i] = item.copyWith(children: newChildren); + } + break; + } + } + } + + void removeSpaceFromFolder(String folderId, String spaceId) { + for (int i = 0; i < _rawItems.length; i++) { + final item = _rawItems[i]; + if (item is SidebarFolder && item.id == folderId) { + final newChildren = item.children.where((c) => c != spaceId).toList(); + + if (newChildren.length <= 1) { + _rawItems.removeAt(i); + for (var child in newChildren) { + _rawItems.insert(i, SidebarSpace(child)); + } + } else { + _rawItems[i] = item.copyWith(children: newChildren); + } + + _rawItems.insert( + (i + 1).clamp(0, _rawItems.length), SidebarSpace(spaceId)); + break; + } + } + + _resolve(); + _debouncedPersist(); + } + + void moveSpaceOutOfFolder(String folderId, String spaceId, int insertIndex) { + for (int i = 0; i < _rawItems.length; i++) { + final item = _rawItems[i]; + if (item is SidebarFolder && item.id == folderId) { + final newChildren = item.children.where((c) => c != spaceId).toList(); + + if (newChildren.length <= 1) { + _rawItems.removeAt(i); + for (var child in newChildren) { + _rawItems.insert(i, SidebarSpace(child)); + } + } else { + _rawItems[i] = item.copyWith(children: newChildren); + } + break; + } + } + + final clampedIndex = insertIndex.clamp(0, _rawItems.length); + _rawItems.insert(clampedIndex, SidebarSpace(spaceId)); + + _resolve(); + _debouncedPersist(); + } + + void renameFolder(String folderId, String newName) { + for (int i = 0; i < _rawItems.length; i++) { + final item = _rawItems[i]; + if (item is SidebarFolder && item.id == folderId) { + _rawItems[i] = item.copyWith(name: newName); + break; + } + } + + _resolve(); + _debouncedPersist(); + } + + void ungroupFolder(String folderId) { + for (int i = 0; i < _rawItems.length; i++) { + final item = _rawItems[i]; + if (item is SidebarFolder && item.id == folderId) { + _rawItems.removeAt(i); + for (int j = 0; j < item.children.length; j++) { + _rawItems.insert(i + j, SidebarSpace(item.children[j])); + } + break; + } + } + + _expandedFolders.remove(folderId); + _resolve(); + _debouncedPersist(); + } + + void reorderWithinFolder(String folderId, int oldIndex, int newIndex) { + for (int i = 0; i < _rawItems.length; i++) { + final item = _rawItems[i]; + if (item is SidebarFolder && item.id == folderId) { + final children = List.from(item.children); + if (oldIndex < 0 || + oldIndex >= children.length || + newIndex < 0 || + newIndex >= children.length) return; + final moved = children.removeAt(oldIndex); + children.insert(newIndex, moved); + _rawItems[i] = item.copyWith(children: children); + break; + } + } + + _resolve(); + _debouncedPersist(); + } + + void toggleFolder(String folderId) { + if (_expandedFolders.contains(folderId)) { + _expandedFolders.remove(folderId); + } else { + _expandedFolders.add(folderId); + } + _resolve(); + } +} diff --git a/commet/lib/client/sidebar/sidebar_model.dart b/commet/lib/client/sidebar/sidebar_model.dart new file mode 100644 index 000000000..41ec92d2a --- /dev/null +++ b/commet/lib/client/sidebar/sidebar_model.dart @@ -0,0 +1,28 @@ +sealed class SidebarItem { + const SidebarItem(); +} + +class SidebarSpace extends SidebarItem { + final String spaceId; + const SidebarSpace(this.spaceId); +} + +class SidebarFolder extends SidebarItem { + final String id; + final String name; + final List children; + + const SidebarFolder({ + required this.id, + required this.name, + required this.children, + }); + + SidebarFolder copyWith({String? name, List? children}) { + return SidebarFolder( + id: id, + name: name ?? this.name, + children: children ?? this.children, + ); + } +} diff --git a/commet/lib/client/sidebar/sidebar_persistence.dart b/commet/lib/client/sidebar/sidebar_persistence.dart new file mode 100644 index 000000000..53f54a886 --- /dev/null +++ b/commet/lib/client/sidebar/sidebar_persistence.dart @@ -0,0 +1,28 @@ +import 'package:commet/client/client.dart'; +import 'package:commet/client/matrix/matrix_client.dart'; +import 'package:commet/client/sidebar/sidebar_data.dart'; + +class SidebarPersistence { + static SidebarData readFromClient(Client client) { + if (client is! MatrixClient) return SidebarData.empty(); + var data = client.matrixClient.accountData[SidebarData.accountDataType]; + if (data == null) return SidebarData.empty(); + return SidebarData.fromJson(data.content); + } + + static Future writeToClient(Client client, SidebarData data) async { + if (client is! MatrixClient) return; + await client.matrixClient.setAccountData( + client.matrixClient.userID!, + SidebarData.accountDataType, + data.toJson(), + ); + } + + static Future writeToAllClients( + List clients, SidebarData data) async { + await Future.wait( + clients.map((client) => writeToClient(client, data)), + ); + } +} diff --git a/commet/lib/ui/atoms/folder_icon.dart b/commet/lib/ui/atoms/folder_icon.dart new file mode 100644 index 000000000..6b60fcc5e --- /dev/null +++ b/commet/lib/ui/atoms/folder_icon.dart @@ -0,0 +1,156 @@ +import 'package:commet/client/client.dart'; +import 'package:commet/config/layout_config.dart'; +import 'package:commet/ui/atoms/notification_badge.dart'; +import 'package:flutter/material.dart'; +import 'package:just_the_tooltip/just_the_tooltip.dart'; +import 'package:tiamat/tiamat.dart' as tiamat; + +class FolderIcon extends StatelessWidget { + const FolderIcon({ + super.key, + required this.name, + required this.spaces, + required this.isExpanded, + required this.onTap, + this.width = 70, + }); + + final String name; + final List spaces; + final bool isExpanded; + final VoidCallback onTap; + final double width; + + int get _highlightedNotificationCount => + spaces.fold(0, (sum, s) => sum + s.displayHighlightedNotificationCount); + + @override + Widget build(BuildContext context) { + final iconSize = width - 14; + final radius = iconSize / 3.4; + final expandedWidth = width - 8; + + final container = AnimatedContainer( + duration: const Duration(milliseconds: 200), + width: isExpanded ? expandedWidth : iconSize, + height: iconSize, + decoration: BoxDecoration( + borderRadius: isExpanded + ? BorderRadius.vertical(top: Radius.circular(radius)) + : BorderRadius.circular(radius), + color: Theme.of(context).colorScheme.surfaceContainerHigh, + ), + clipBehavior: Clip.antiAlias, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + child: isExpanded + ? _buildFolderOpenIcon(context, iconSize) + : _buildGrid(context, iconSize), + ), + ); + + Widget content = GestureDetector( + onTap: onTap, + child: SizedBox( + width: width, + height: iconSize + (isExpanded ? 2 : 4), + child: Padding( + padding: EdgeInsets.only(top: 2, bottom: isExpanded ? 0 : 2), + child: Center(child: container), + ), + ), + ); + + if (!Layout.mobile) { + content = JustTheTooltip( + content: Padding( + padding: const EdgeInsets.all(8.0), + child: tiamat.Text(name), + ), + preferredDirection: AxisDirection.right, + offset: 5, + tailLength: 5, + tailBaseWidth: 5, + backgroundColor: Theme.of(context).colorScheme.surfaceContainerLowest, + child: content, + ); + } + + return Stack(children: [ + content, + if (_highlightedNotificationCount > 0) + Positioned( + right: 0, + top: 0, + child: SizedBox( + width: 20, + height: 20, + child: NotificationBadge(_highlightedNotificationCount), + ), + ), + ]); + } + + Widget _buildFolderOpenIcon(BuildContext context, double size) { + return SizedBox( + key: const ValueKey('folder_open'), + width: size, + height: size, + child: Icon( + Icons.folder_open, + size: size * 0.5, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ); + } + + Widget _buildGrid(BuildContext context, double size) { + final displaySpaces = spaces.take(4).toList(); + final cellSize = (size - 6) / 2; + + return SizedBox( + key: const ValueKey('grid'), + child: Padding( + padding: const EdgeInsets.all(2), + child: Wrap( + spacing: 2, + runSpacing: 2, + children: [ + for (var space in displaySpaces) + SizedBox( + width: cellSize, + height: cellSize, + child: ClipRRect( + borderRadius: BorderRadius.circular(4), + child: space.avatar != null + ? Image( + image: space.avatar!, + fit: BoxFit.cover, + ) + : tiamat.Avatar( + radius: cellSize / 2, + placeholderColor: space.color, + placeholderText: space.displayName, + ), + ), + ), + for (var i = displaySpaces.length; i < 4; i++) + SizedBox( + width: cellSize, + height: cellSize, + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + color: Theme.of(context) + .colorScheme + .surfaceContainerHighest + .withValues(alpha: 0.3), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/commet/lib/ui/molecules/draggable_space_selector.dart b/commet/lib/ui/molecules/draggable_space_selector.dart new file mode 100644 index 000000000..06201a7c9 --- /dev/null +++ b/commet/lib/ui/molecules/draggable_space_selector.dart @@ -0,0 +1,644 @@ +import 'dart:async'; + +import 'package:commet/client/client.dart'; +import 'package:commet/client/sidebar/resolved_sidebar_item.dart'; +import 'package:commet/client/sidebar/sidebar_manager.dart'; +import 'package:commet/config/build_config.dart'; +import 'package:commet/config/layout_config.dart'; +import 'package:commet/ui/atoms/dot_indicator.dart'; +import 'package:commet/ui/atoms/folder_icon.dart'; +import 'package:commet/ui/atoms/space_icon.dart'; +import 'package:commet/ui/molecules/space_selector.dart'; +import 'package:commet/ui/atoms/adaptive_context_menu.dart'; +import 'package:commet/utils/scaled_app.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:intl/intl.dart'; +import 'package:tiamat/tiamat.dart' as tiamat; + +class SidebarDragData { + final String? spaceId; + final String? folderId; + final String? fromFolderId; + final int sourceIndex; + + const SidebarDragData({ + this.spaceId, + this.folderId, + this.fromFolderId, + required this.sourceIndex, + }); + + bool get isDraggingFolder => folderId != null; + bool get isDraggingSpace => spaceId != null; +} + +class DraggableSpaceSelector extends StatefulWidget { + const DraggableSpaceSelector( + this.items, { + super.key, + required this.width, + required this.sidebarManager, + this.onSpaceSelected, + this.clearSelection, + this.shouldShowAvatarForSpace, + this.header, + this.footer, + }); + + final List items; + final double width; + final SidebarManager sidebarManager; + final void Function(Space space)? onSpaceSelected; + final void Function()? clearSelection; + final bool Function(Space space)? shouldShowAvatarForSpace; + final Widget? header; + final Widget? footer; + + @override + State createState() => _DraggableSpaceSelectorState(); +} + +class _DraggableSpaceSelectorState extends State { + String get _promptCreateFolder => Intl.message("Create Folder", + name: "promptCreateFolder", desc: "Title for create folder dialog"); + + String get _promptRenameFolder => Intl.message("Rename Folder", + name: "promptRenameFolder", desc: "Title for rename folder dialog"); + + String get _promptFolderName => Intl.message("Folder name", + name: "promptFolderName", desc: "Hint text for folder name input"); + + String get _promptRename => Intl.message("Rename", + name: "promptRename", desc: "Button label to rename"); + + String get _promptCreate => Intl.message("Create", + name: "promptCreate", desc: "Button label to create"); + + String get _promptCancel => Intl.message("Cancel", + name: "promptCancel", desc: "Button label to cancel"); + + String get _promptUngroup => Intl.message("Ungroup", + name: "promptUngroup", desc: "Context menu option to ungroup a folder"); + + String get _promptRemoveFromFolder => Intl.message("Remove from folder", + name: "promptRemoveFromFolder", + desc: "Context menu option to remove a space from a folder"); + + bool _isDragging = false; + double _dragDistance = 0; + Timer? _contextMenuTimer; + List? _pendingContextItems; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Flexible( + child: ScrollConfiguration( + behavior: + ScrollConfiguration.of(context).copyWith(scrollbars: false), + child: SingleChildScrollView( + physics: + BuildConfig.ANDROID ? const BouncingScrollPhysics() : null, + child: Padding( + padding: EdgeInsets.fromLTRB( + 0, 0, 0, MediaQuery.of(context).scale().padding.bottom), + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (widget.header != null) + Padding( + padding: SpaceSelector.padding, + child: widget.header!, + ), + if (widget.header != null) const tiamat.Seperator(), + for (int i = 0; i < widget.items.length; i++) + _buildDragItem(i, widget.items[i]), + if (_isDragging) _buildInsertionZone(widget.items.length), + if (widget.footer != null) + Padding( + padding: SpaceSelector.padding, + child: widget.footer!, + ), + ], + ), + ), + ), + ), + ), + ], + ); + } + + Widget _buildDragItem(int index, ResolvedSidebarItem item) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (_isDragging) _buildInsertionZone(index), + switch (item) { + ResolvedSpace s => _buildDraggableSpace(index, s), + ResolvedFolder f => _buildDraggableFolder(index, f), + }, + ], + ); + } + + Widget _buildInsertionZone(int insertIndex) { + return DragTarget( + onWillAcceptWithDetails: (_) => true, + onAcceptWithDetails: (details) { + _handleInsertionDrop(details.data, insertIndex); + }, + builder: (context, candidates, rejected) { + final isHovered = candidates.isNotEmpty; + return AnimatedContainer( + duration: const Duration(milliseconds: 150), + height: isHovered ? 8 : 4, + margin: SpaceSelector.padding, + decoration: isHovered + ? BoxDecoration( + color: Theme.of(context).colorScheme.primary, + borderRadius: BorderRadius.circular(2), + ) + : null, + ); + }, + ); + } + + Widget _buildFolderInsertionZone(String folderId, int insertIndex) { + return DragTarget( + onWillAcceptWithDetails: (details) => details.data.isDraggingSpace, + onAcceptWithDetails: (details) { + if (details.data.fromFolderId == folderId) { + widget.sidebarManager.reorderWithinFolder( + folderId, + details.data.sourceIndex, + insertIndex, + ); + } else { + widget.sidebarManager + .addSpaceToFolderAt(folderId, details.data.spaceId!, insertIndex); + } + }, + builder: (context, candidates, rejected) { + final isHovered = candidates.isNotEmpty; + return AnimatedContainer( + duration: const Duration(milliseconds: 150), + height: isHovered ? 6 : 2, + margin: const EdgeInsets.symmetric(horizontal: 4), + decoration: isHovered + ? BoxDecoration( + color: Theme.of(context).colorScheme.primary, + borderRadius: BorderRadius.circular(2), + ) + : null, + ); + }, + ); + } + + void _handleInsertionDrop(SidebarDragData data, int insertIndex) { + if (data.isDraggingFolder) { + widget.sidebarManager.reorder(data.sourceIndex, insertIndex); + } else if (data.fromFolderId != null) { + widget.sidebarManager + .moveSpaceOutOfFolder(data.fromFolderId!, data.spaceId!, insertIndex); + } else { + widget.sidebarManager.reorder(data.sourceIndex, insertIndex); + } + } + + Widget _buildDraggableSpace(int index, ResolvedSpace item, + {String? folderId, int? indexInFolder, double? widthOverride}) { + final dragData = SidebarDragData( + spaceId: item.space.identifier, + fromFolderId: folderId, + sourceIndex: folderId != null ? (indexInFolder ?? index) : index, + ); + final w = widthOverride ?? widget.width; + + Widget spaceIcon = _buildSpaceIconWidget(item.space, width: w); + + Widget content = folderId == null + ? DragTarget( + onWillAcceptWithDetails: (details) => + details.data.isDraggingSpace && + details.data.spaceId != item.space.identifier, + onAcceptWithDetails: (details) { + _showCreateFolderDialog(details.data, item, index); + }, + builder: (context, candidates, rejected) { + final isHovered = candidates.isNotEmpty; + Widget child = spaceIcon; + if (isHovered) { + child = Container( + decoration: BoxDecoration( + border: Border.all( + color: Theme.of(context).colorScheme.primary, + width: 2, + ), + borderRadius: BorderRadius.circular(12), + ), + child: child, + ); + } + return child; + }, + ) + : spaceIcon; + + final contextItems = [ + if (folderId != null) + tiamat.ContextMenuItem( + text: _promptRemoveFromFolder, + icon: Icons.folder_off, + onPressed: () => widget.sidebarManager + .removeSpaceFromFolder(folderId, item.space.identifier), + ), + ]; + + return _wrapDraggable( + dragData, + contextItems.isNotEmpty + ? AdaptiveContextMenu(items: contextItems, child: content) + : content, + avatar: item.space.avatar, + placeholderColor: item.space.color, + placeholderText: item.space.displayName, + ); + } + + Widget _buildSpaceIconWidget(Space space, {double? width}) { + final w = width ?? widget.width; + return Stack( + alignment: Alignment.centerLeft, + children: [ + Padding( + padding: SpaceSelector.padding, + child: Padding( + padding: const EdgeInsets.fromLTRB(0, 2, 0, 2), + child: SpaceIcon( + displayName: space.displayName, + onUpdate: space.onUpdate, + avatar: space.avatar, + userAvatar: space.client.self!.avatar, + spaceId: space.identifier, + userColor: space.client.self!.defaultColor, + userDisplayName: space.client.self!.displayName, + highlightedNotificationCount: + space.displayHighlightedNotificationCount, + notificationCount: space.displayNotificationCount, + width: w, + placeholderColor: space.color, + onTap: () { + widget.onSpaceSelected?.call(space); + }, + showUser: widget.shouldShowAvatarForSpace?.call(space) ?? false, + ), + ), + ), + if (space.displayNotificationCount > 0) _messageOverlay(), + ], + ); + } + + Widget _wrapDraggable( + SidebarDragData data, + Widget child, { + ImageProvider? avatar, + Color? placeholderColor, + String? placeholderText, + Widget? customFeedback, + List? mobileContextItems, + }) { + final feedbackWidget = customFeedback ?? + Material( + color: Colors.transparent, + child: Opacity( + opacity: 0.8, + child: SizedBox( + width: 56, + height: 56, + child: tiamat.Avatar( + radius: 28, + image: avatar, + placeholderColor: placeholderColor, + placeholderText: placeholderText, + ), + ), + ), + ); + + void onDragStarted() { + HapticFeedback.mediumImpact(); + _dragDistance = 0; + _contextMenuTimer?.cancel(); + + if (!Layout.desktop && + mobileContextItems != null && + mobileContextItems.isNotEmpty) { + _pendingContextItems = mobileContextItems; + _contextMenuTimer = Timer(const Duration(milliseconds: 1200), () { + if (_dragDistance < 50 && _pendingContextItems != null) { + HapticFeedback.mediumImpact(); + _showMobileContextMenu(_pendingContextItems!); + _pendingContextItems = null; + } + }); + } + + setState(() => _isDragging = true); + } + + void onDragUpdate(DragUpdateDetails details) { + _dragDistance += details.delta.distance; + if (_dragDistance >= 50) { + _contextMenuTimer?.cancel(); + _contextMenuTimer = null; + _pendingContextItems = null; + } + } + + void onDragEnded(DraggableDetails details) { + final wasDragging = _isDragging; + setState(() => _isDragging = false); + _contextMenuTimer?.cancel(); + _contextMenuTimer = null; + + _pendingContextItems = null; + + if (!details.wasAccepted && + wasDragging && + data.fromFolderId != null && + data.spaceId != null) { + widget.sidebarManager + .removeSpaceFromFolder(data.fromFolderId!, data.spaceId!); + } + } + + if (Layout.desktop) { + return Draggable( + data: data, + feedback: feedbackWidget, + childWhenDragging: Opacity(opacity: 0.3, child: child), + onDragStarted: onDragStarted, + onDragUpdate: onDragUpdate, + onDragEnd: onDragEnded, + child: child, + ); + } else { + return LongPressDraggable( + data: data, + delay: const Duration(milliseconds: 300), + feedback: feedbackWidget, + childWhenDragging: Opacity(opacity: 0.3, child: child), + hapticFeedbackOnStart: true, + onDragStarted: onDragStarted, + onDragUpdate: onDragUpdate, + onDragEnd: onDragEnded, + child: child, + ); + } + } + + void _showMobileContextMenu(List items) { + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + contentPadding: const EdgeInsets.symmetric(vertical: 8), + content: Column( + mainAxisSize: MainAxisSize.min, + children: items + .map((item) => ListTile( + leading: item.icon != null ? Icon(item.icon) : null, + title: Text(item.text), + onTap: () { + Navigator.of(dialogContext).pop(); + item.onPressed?.call(); + }, + )) + .toList(), + ), + ), + ); + } + + Widget _buildDraggableFolder(int index, ResolvedFolder folder) { + final folderContextItems = [ + tiamat.ContextMenuItem( + text: _promptRename, + icon: Icons.edit, + onPressed: () => _showRenameFolderDialog(folder), + ), + tiamat.ContextMenuItem( + text: _promptUngroup, + icon: Icons.folder_off, + onPressed: () => widget.sidebarManager.ungroupFolder(folder.id), + ), + ]; + + final dragData = SidebarDragData( + folderId: folder.id, + sourceIndex: index, + ); + + Widget folderContent = Column( + mainAxisSize: MainAxisSize.min, + children: [ + DragTarget( + onWillAcceptWithDetails: (details) => + details.data.isDraggingSpace && + !folder.spaces.any((s) => s.identifier == details.data.spaceId), + onAcceptWithDetails: (details) { + if (details.data.fromFolderId != null) { + widget.sidebarManager.removeSpaceFromFolder( + details.data.fromFolderId!, details.data.spaceId!); + } + widget.sidebarManager + .addSpaceToFolder(folder.id, details.data.spaceId!); + }, + builder: (context, candidates, rejected) { + final isHovered = candidates.isNotEmpty; + Widget icon = Stack( + alignment: Alignment.centerLeft, + children: [ + FolderIcon( + name: folder.name, + spaces: folder.spaces, + isExpanded: folder.isExpanded, + width: widget.width, + onTap: () => widget.sidebarManager.toggleFolder(folder.id), + ), + if (_folderHasNotifications(folder)) _messageOverlay(), + ], + ); + + if (isHovered) { + icon = Container( + decoration: BoxDecoration( + border: Border.all( + color: Theme.of(context).colorScheme.primary, + width: 2, + ), + borderRadius: BorderRadius.circular(14), + ), + child: icon, + ); + } + return icon; + }, + ), + AnimatedSize( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + alignment: Alignment.topCenter, + child: folder.isExpanded + ? _buildExpandedFolderChildren(folder) + : const SizedBox.shrink(), + ), + ], + ); + + Widget wrapped = Layout.desktop + ? AdaptiveContextMenu( + items: folderContextItems, + child: folderContent, + ) + : folderContent; + + return _wrapDraggable( + dragData, + wrapped, + mobileContextItems: folderContextItems, + customFeedback: Material( + color: Colors.transparent, + child: Opacity( + opacity: 0.8, + child: Container( + width: 56, + height: 56, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(56 / 3.4), + color: Theme.of(context).colorScheme.surfaceContainerHigh, + ), + child: Icon( + Icons.folder, + size: 28, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + ), + ); + } + + Widget _buildExpandedFolderChildren(ResolvedFolder folder) { + final spaces = folder.spaces; + final bottomRadius = (widget.width - 14) / 3.4; + return Container( + margin: const EdgeInsets.fromLTRB(4, 0, 4, 2), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + borderRadius: + BorderRadius.vertical(bottom: Radius.circular(bottomRadius)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (int i = 0; i < spaces.length; i++) ...[ + if (_isDragging) _buildFolderInsertionZone(folder.id, i), + _buildDraggableSpace( + -1, + ResolvedSpace(spaces[i]), + folderId: folder.id, + indexInFolder: i, + widthOverride: widget.width - 8, + ), + ], + if (_isDragging) _buildFolderInsertionZone(folder.id, spaces.length), + ], + ), + ); + } + + bool _folderHasNotifications(ResolvedFolder folder) { + return folder.spaces.any((s) => s.displayNotificationCount > 0); + } + + Widget _messageOverlay() { + return const Positioned(left: -6, child: DotIndicator()); + } + + void _showCreateFolderDialog( + SidebarDragData dragData, ResolvedSpace targetItem, int targetIndex) { + showDialog( + context: context, + builder: (dialogContext) { + final controller = TextEditingController(); + return AlertDialog( + title: Text(_promptCreateFolder), + content: TextField( + controller: controller, + autofocus: true, + decoration: InputDecoration(hintText: _promptFolderName), + onSubmitted: (value) => Navigator.of(dialogContext).pop(value), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: Text(_promptCancel), + ), + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(controller.text), + child: Text(_promptCreate), + ), + ], + ); + }, + ).then((name) { + if (name != null && name.isNotEmpty) { + widget.sidebarManager.createFolder( + name, + dragData.spaceId!, + targetItem.space.identifier, + targetIndex, + ); + } + }); + } + + void _showRenameFolderDialog(ResolvedFolder folder) { + showDialog( + context: context, + builder: (dialogContext) { + final controller = TextEditingController(text: folder.name); + return AlertDialog( + title: Text(_promptRenameFolder), + content: TextField( + controller: controller, + autofocus: true, + decoration: InputDecoration(hintText: _promptFolderName), + onSubmitted: (value) => Navigator.of(dialogContext).pop(value), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: Text(_promptCancel), + ), + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(controller.text), + child: Text(_promptRename), + ), + ], + ); + }, + ).then((name) { + if (name != null && name.isNotEmpty) { + widget.sidebarManager.renameFolder(folder.id, name); + } + }); + } +} diff --git a/commet/lib/ui/organisms/side_navigation_bar/side_navigation_bar.dart b/commet/lib/ui/organisms/side_navigation_bar/side_navigation_bar.dart index 35f9a2e33..7cbb81a86 100644 --- a/commet/lib/ui/organisms/side_navigation_bar/side_navigation_bar.dart +++ b/commet/lib/ui/organisms/side_navigation_bar/side_navigation_bar.dart @@ -3,12 +3,12 @@ import 'dart:async'; import 'package:commet/client/client.dart'; import 'package:commet/client/client_manager.dart'; import 'package:commet/client/components/profile/profile_component.dart'; +import 'package:commet/client/sidebar/resolved_sidebar_item.dart'; import 'package:commet/config/layout_config.dart'; -import 'package:commet/ui/molecules/space_selector.dart'; +import 'package:commet/ui/molecules/draggable_space_selector.dart'; import 'package:commet/ui/organisms/side_navigation_bar/side_navigation_bar_direct_messages.dart'; import 'package:commet/ui/pages/get_or_create_room/get_or_create_room.dart'; import 'package:commet/utils/common_strings.dart'; -import 'package:commet/utils/event_bus.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:just_the_tooltip/just_the_tooltip.dart'; @@ -75,52 +75,37 @@ class _SideNavigationBarState extends State { String get promptAddSpace => Intl.message("Add Space", name: "promptAddSpace", desc: "Prompt to add a new space"); - late List topLevelSpaces; - - Client? filterClient; + late List sidebarItems; @override void initState() { _clientManager = Provider.of(context, listen: false); - void setFilterClient(Client? event) { - setState(() { - filterClient = event; - - getSpaces(); - }); - } - subs = [ - _clientManager.onSpaceChildUpdated.stream.listen((_) => onSpaceUpdate()), - _clientManager.onSpaceUpdated.stream.listen((_) => onSpaceUpdate()), - _clientManager.onSpaceRemoved.listen((_) => onSpaceUpdate()), - _clientManager.onSpaceAdded.listen((_) => onSpaceUpdate()), - _clientManager.onClientRemoved.stream.listen((_) => onSpaceUpdate()), + _clientManager.onSpaceRemoved.listen((_) => onSidebarUpdate()), + _clientManager.onSpaceAdded.listen((_) => onSidebarUpdate()), + _clientManager.onClientRemoved.stream.listen((_) => onSidebarUpdate()), + _clientManager.sidebarManager.onSidebarChanged.stream + .listen((_) => onSidebarUpdate()), + _clientManager.onSpaceChildUpdated.stream + .listen((_) => onSidebarUpdate()), + _clientManager.onSpaceUpdated.stream.listen((_) => onSidebarUpdate()), _clientManager.onDirectMessageRoomUpdated.stream .listen(onDirectMessageUpdated), - EventBus.setFilterClient.stream.listen(setFilterClient), ]; - getSpaces(); + getSidebarItems(); super.initState(); } - void getSpaces() { - if (filterClient != null) { - topLevelSpaces = filterClient!.spaces.where((e) => e.isTopLevel).toList(); - } else { - _clientManager = Provider.of(context, listen: false); - - topLevelSpaces = - _clientManager.spaces.where((e) => e.isTopLevel).toList(); - } + void getSidebarItems() { + sidebarItems = _clientManager.sidebarManager.items; } - void onSpaceUpdate() { + void onSidebarUpdate() { setState(() { - getSpaces(); + getSidebarItems(); }); } @@ -143,9 +128,10 @@ class _SideNavigationBarState extends State { child: Column( children: [ Expanded( - child: SpaceSelector( - topLevelSpaces, + child: DraggableSpaceSelector( + sidebarItems, width: 70, + sidebarManager: _clientManager.sidebarManager, clearSelection: widget.clearSpaceSelection, shouldShowAvatarForSpace: shouldShowAvatarForSpace, header: Column( @@ -188,7 +174,7 @@ class _SideNavigationBarState extends State { ), ], ), - onSelected: (space) { + onSpaceSelected: (space) { widget.onSpaceSelected?.call(space); }, ), diff --git a/pubspec.lock b/pubspec.lock index 465abe0ae..dd7f6540b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -2432,4 +2432,4 @@ packages: version: "2.1.0" sdks: dart: ">=3.10.3 <4.0.0" - flutter: ">=3.38.4" + flutter: ">=3.38.4" \ No newline at end of file