diff --git a/Anytype/Sources/PresentationLayer/Modules/SpaceHub/SpaceHubDropDelegate.swift b/Anytype/Sources/PresentationLayer/Modules/SpaceHub/SpaceHubDropDelegate.swift index 585859bcaa..1bd4e174c2 100644 --- a/Anytype/Sources/PresentationLayer/Modules/SpaceHub/SpaceHubDropDelegate.swift +++ b/Anytype/Sources/PresentationLayer/Modules/SpaceHub/SpaceHubDropDelegate.swift @@ -5,9 +5,9 @@ import AnytypeCore struct SpaceHubDropDelegate: DropDelegate { - let destinationItem: ParticipantSpaceViewDataWithPreview + let destinationSpaceViewId: String? @Binding var allSpaces: [ParticipantSpaceViewDataWithPreview]? - @Binding var draggedItem: ParticipantSpaceViewDataWithPreview? + @Binding var draggedSpaceViewId: String? @Binding var initialIndex: Int? func dropUpdated(info: DropInfo) -> DropProposal? { @@ -15,20 +15,20 @@ struct SpaceHubDropDelegate: DropDelegate { } func performDrop(info: DropInfo) -> Bool { - guard let allSpaces, draggedItem.isNotNil, let initialIndex else { return false } + guard let allSpaces, draggedSpaceViewId.isNotNil, let initialIndex else { return false } - guard let finalIndex = allSpaces.firstIndex(of: destinationItem) else { return false } + guard let finalIndex = allSpaces.firstIndex(where: { $0.spaceView.id == destinationSpaceViewId }) else { return false } guard finalIndex != initialIndex else { return false } - self.draggedItem = nil + self.draggedSpaceViewId = nil self.initialIndex = nil return true } func dropEntered(info: DropInfo) { - guard var allSpaces, let draggedItem else { return } - guard let fromIndex = allSpaces.firstIndex(where: { $0.space.id == draggedItem.space.id } ) else { return } - guard let toIndex = allSpaces.firstIndex(where: { $0.space.id == destinationItem.space.id } ) else { return } + guard var allSpaces, let draggedSpaceViewId else { return } + guard let fromIndex = allSpaces.firstIndex(where: { $0.space.spaceView.id == draggedSpaceViewId } ) else { return } + guard let toIndex = allSpaces.firstIndex(where: { $0.space.spaceView.id == destinationSpaceViewId } ) else { return } guard fromIndex != toIndex else { return } @@ -52,7 +52,7 @@ struct SpaceHubDropDelegate: DropDelegate { let spaceOrderService = Container.shared.spaceOrderService() try await spaceOrderService.setOrder( - spaceViewIdMoved: draggedItem.spaceView.id, newOrder: newOrder + spaceViewIdMoved: draggedSpaceViewId, newOrder: newOrder ) AnytypeAnalytics.instance().logReorderSpace() } diff --git a/Anytype/Sources/PresentationLayer/Modules/SpaceHub/SpaceHubView.swift b/Anytype/Sources/PresentationLayer/Modules/SpaceHub/SpaceHubView.swift index f75e69e531..d503da8ec1 100644 --- a/Anytype/Sources/PresentationLayer/Modules/SpaceHub/SpaceHubView.swift +++ b/Anytype/Sources/PresentationLayer/Modules/SpaceHub/SpaceHubView.swift @@ -5,9 +5,6 @@ import DesignKit struct SpaceHubView: View { @State private var model: SpaceHubViewModel - @State private var draggedSpace: ParticipantSpaceViewDataWithPreview? - @State private var draggedInitialIndex: Int? - @State private var vaultBackToRootsToggle = FeatureFlags.vaultBackToRoots private var namespace: Namespace.ID @@ -34,8 +31,8 @@ struct SpaceHubView: View { @ViewBuilder private var content: some View { Group { - if let spaces = model.spaces { - spacesView(spaces) + if model.dataLoaded { + spacesView() } else { EmptyView() // Do not show empty state view if we do not receive data yet } @@ -43,44 +40,21 @@ struct SpaceHubView: View { Spacer() } .ignoresSafeArea(edges: .bottom) - .animation(.default, value: model.spaces) } - private func spacesView(_ spaces: [ParticipantSpaceViewDataWithPreview]) -> some View { + private func spacesView() -> some View { NavigationStack { - Group { - if spaces.isEmpty { - emptyStateView - } else if model.filteredSpaces.isNotEmpty { - scrollView - } else { - SpaceHubSearchEmptySpaceView() + SpaceHubList(model: model) + .navigationTitle(Loc.myChannels) + .navigationBarTitleDisplayMode(.inline) + .toolbar { toolbarItems } + .searchable(text: $model.searchText) + .onChange(of: model.searchText) { + model.searchTextUpdated() } - } - .navigationTitle(Loc.myChannels) - .navigationBarTitleDisplayMode(.inline) - .toolbar { toolbarItems } - .searchable(text: $model.searchText) - .onChange(of: model.searchText) { - model.searchTextUpdated() - } }.tint(Color.Text.secondary) } - private var scrollView: some View { - ScrollView { - VStack(spacing: vaultBackToRootsToggle ? 8 : 0) { - HomeUpdateSubmoduleView().padding(8) - - ForEach(model.filteredSpaces) { - spaceCard($0) - } - - Spacer.fixedHeight(40) - } - } - } - private var toolbarItems: some ToolbarContent { SpaceHubToolbar( showLoading: model.showLoading, @@ -95,54 +69,6 @@ struct SpaceHubView: View { } ) } - - private var emptyStateView: some View { - SpaceHubEmptyStateView { - model.onTapCreateSpace() - } - } - - private func spaceCard(_ space: ParticipantSpaceViewDataWithPreview) -> some View { - SpaceCard( - spaceData: space, - wallpaper: model.wallpapers[space.spaceView.targetSpaceId] ?? .default, - draggedSpace: $draggedSpace, - onTap: { - model.onSpaceTap(spaceId: space.spaceView.targetSpaceId) - }, - onTapCopy: { - model.copySpaceInfo(spaceView: space.spaceView) - }, - onTapMute: { - model.muteSpace(spaceView: space.spaceView) - }, - onTapPin: { - try await model.pin(spaceView: space.spaceView) - }, - onTapUnpin: { - try await model.unpin(spaceView: space.spaceView) - }, - onTapSettings: { - model.openSpaceSettings(spaceId: space.spaceView.targetSpaceId) - }, - onTapDelete: { - model.onDeleteSpace(spaceId: space.spaceView.targetSpaceId) - } - ) - .equatable() - .padding(.horizontal, vaultBackToRootsToggle ? 16 : 0) - .if(space.spaceView.isPinned) { - $0.onDrop( - of: [.text], - delegate: SpaceHubDropDelegate( - destinationItem: space, - allSpaces: $model.spaces, - draggedItem: $draggedSpace, - initialIndex: $draggedInitialIndex - ) - ) - } - } } #Preview { diff --git a/Anytype/Sources/PresentationLayer/Modules/SpaceHub/SpaceHubViewModel.swift b/Anytype/Sources/PresentationLayer/Modules/SpaceHub/SpaceHubViewModel.swift index 7c3f6e9be7..8785caeef9 100644 --- a/Anytype/Sources/PresentationLayer/Modules/SpaceHub/SpaceHubViewModel.swift +++ b/Anytype/Sources/PresentationLayer/Modules/SpaceHub/SpaceHubViewModel.swift @@ -8,10 +8,11 @@ import Loc @MainActor @Observable final class SpaceHubViewModel { + var spaces: [ParticipantSpaceViewDataWithPreview]? + var dataLoaded = false var searchText: String = "" - - var filteredSpaces: [ParticipantSpaceViewDataWithPreview] = [] + var filteredSpaces: [SpaceCardModel] = [] var wallpapers: [String: SpaceWallpaperType] = [:] @@ -23,7 +24,6 @@ final class SpaceHubViewModel { @ObservationIgnored private weak var output: (any SpaceHubModuleOutput)? - @Injected(\.userDefaultsStorage) @ObservationIgnored private var userDefaults: any UserDefaultsStorageProtocol @@ -39,6 +39,8 @@ final class SpaceHubViewModel { private var pushNotificationsSystemSettingsBroadcaster: any PushNotificationsSystemSettingsBroadcasterProtocol @Injected(\.workspaceService) @ObservationIgnored private var workspaceService: any WorkspaceServiceProtocol + @Injected(\.spaceCardModelBuilder)@ObservationIgnored + private var spaceCardModelBuilder: any SpaceCardModelBuilderProtocol init(output: (any SpaceHubModuleOutput)?) { self.output = output @@ -62,11 +64,13 @@ final class SpaceHubViewModel { } - func copySpaceInfo(spaceView: SpaceView) { + func copySpaceInfo(spaceViewId: String) { + guard let spaceView = spaces?.first(where: { $0.spaceView.id == spaceViewId })?.spaceView else { return } UIPasteboard.general.string = String(describing: spaceView) } - func muteSpace(spaceView: SpaceView) { + func muteSpace(spaceViewId: String) { + guard let spaceView = spaces?.first(where: { $0.spaceView.id == spaceViewId })?.spaceView else { return } let isUnmutedAll = spaceView.pushNotificationMode.isUnmutedAll spaceMuteData = SpaceMuteData( spaceId: spaceView.targetSpaceId, @@ -74,19 +78,19 @@ final class SpaceHubViewModel { ) } - func pin(spaceView: SpaceView) async throws { + func pin(spaceViewId: String) async throws { guard let spaces else { return } let pinnedSpaces = spaces.filter { $0.spaceView.isPinned } + + var newOrder = pinnedSpaces.filter { $0.spaceView.id != spaceViewId }.map(\.spaceView.id) + newOrder.insert(spaceViewId, at: 0) - var newOrder = pinnedSpaces.filter { $0.spaceView.id != spaceView.id }.map(\.spaceView.id) - newOrder.insert(spaceView.id, at: 0) - - try await spaceOrderService.setOrder(spaceViewIdMoved: spaceView.id, newOrder: newOrder) + try await spaceOrderService.setOrder(spaceViewIdMoved: spaceViewId, newOrder: newOrder) AnytypeAnalytics.instance().logPinSpace() } - func unpin(spaceView: SpaceView) async throws { - try await spaceOrderService.unsetOrder(spaceViewId: spaceView.id) + func unpin(spaceViewId: String) async throws { + try await spaceOrderService.unsetOrder(spaceViewId: spaceViewId) AnytypeAnalytics.instance().logUnpinSpace() } @@ -120,7 +124,9 @@ final class SpaceHubViewModel { } func searchTextUpdated() { - updateFilteredSpaces() + Task { + await updateFilteredSpaces() + } } // MARK: - Private @@ -128,7 +134,8 @@ final class SpaceHubViewModel { for await spaces in await spaceHubSpacesStorage.spacesStream { self.spaces = spaces.sorted(by: sortSpacesForPinnedFeature) showLoading = spaces.contains { $0.spaceView.isLoading } - updateFilteredSpaces() + await updateFilteredSpaces() + self.dataLoaded = spaces.isNotEmpty } } @@ -207,20 +214,21 @@ final class SpaceHubViewModel { } } - private func updateFilteredSpaces() { - + private func updateFilteredSpaces() async { guard let spaces else { filteredSpaces = [] return } - guard !searchText.isEmpty else { - filteredSpaces = spaces - return + let spacesToFilter: [ParticipantSpaceViewDataWithPreview] + if searchText.isEmpty { + spacesToFilter = spaces + } else { + spacesToFilter = spaces.filter { space in + space.spaceView.name.localizedCaseInsensitiveContains(searchText) + } } - filteredSpaces = spaces.filter { space in - space.spaceView.name.localizedCaseInsensitiveContains(searchText) - } + self.filteredSpaces = await spaceCardModelBuilder.build(from: spacesToFilter, wallpapers: wallpapers) } } diff --git a/Anytype/Sources/PresentationLayer/Modules/SpaceHub/Subviews/NewSpaceCardLabel.swift b/Anytype/Sources/PresentationLayer/Modules/SpaceHub/Subviews/NewSpaceCardLabel.swift deleted file mode 100644 index 8681065564..0000000000 --- a/Anytype/Sources/PresentationLayer/Modules/SpaceHub/Subviews/NewSpaceCardLabel.swift +++ /dev/null @@ -1,203 +0,0 @@ -import SwiftUI -import AnytypeCore - -// SpaceCardLabel and SpaceCard are splitted for better SwiftUI diff. -struct NewSpaceCardLabel: View { - - let spaceData: ParticipantSpaceViewDataWithPreview - let wallpaper: SpaceWallpaperType - private let dateFormatter = ChatPreviewDateFormatter() - @Binding var draggedSpace: ParticipantSpaceViewDataWithPreview? - - @Namespace private var namespace - - var body: some View { - content - .if(spaceData.spaceView.isPinned) { - $0.onDrag { - draggedSpace = spaceData - return NSItemProvider() - } preview: { - EmptyView() - } - } - } - - private var content: some View { - HStack(alignment: .center, spacing: 12) { - IconView(icon: spaceData.spaceView.objectIconImage) - .frame(width: 56, height: 56) - - Group { - if let message = spaceData.preview.lastMessage { - mainContentWithMessage(message) - } else { - mainContentWithoutMessage - } - } - // Fixing the animation when the cell is moved and updated inside - // Optimization - create a data model for SpaceCard and map to in in SpaceHubViewModel on background thread - .id(spaceData.hashValue) - .matchedGeometryEffect(id: "content", in: namespace, properties: .position, anchor: .topLeading) - } - .padding(.horizontal, 16) - .padding(.vertical, 17) - // Optimization for fast sizeThatFits - .frame(height: 98) - - .cornerRadius(20, style: .continuous) - .background(DashboardWallpaper( - mode: .spaceHub, - wallpaper: wallpaper, - spaceIcon: spaceData.spaceView.objectIconImage - )) - .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) - } - - private func mainContentWithMessage(_ message: LastMessagePreview) -> some View { - VStack(alignment: .leading, spacing: 0) { - HStack(alignment: .bottom) { - HStack(alignment: .center) { - AnytypeText(spaceData.spaceView.name.withPlaceholder, style: .bodySemibold) - .lineLimit(1) - .foregroundColor(Color.Text.primary) - if isMuted { - Spacer.fixedWidth(4) - Image(asset: .X18.muted).foregroundColor(.Control.transparentSecondary) - } - } - - Spacer(minLength: 8) - - VStack(spacing: 0) { - lastMessageDate - Spacer.fixedHeight(2) - } - } - - HStack(alignment: .top) { - lastMessagePreview(message) - Spacer() - decoration - } - - Spacer(minLength: 0) - } - } - - private var mainContentWithoutMessage: some View { - VStack(alignment: .leading, spacing: 0) { - HStack { - AnytypeText(spaceData.spaceView.name.withPlaceholder, style: .bodySemibold) - .lineLimit(1) - .foregroundColor(Color.Text.primary) - if isMuted { - Spacer.fixedWidth(8) - Image(asset: .X18.muted).foregroundColor(.Control.transparentSecondary) - } - Spacer() - pin - } - } - } - - @ViewBuilder - func lastMessagePreview(_ message: LastMessagePreview) -> some View { - Group { - if message.attachments.isNotEmpty { - messageWithAttachements(message) - } else if message.text.isNotEmpty { - messageWithoutAttachements(message) - } else { - AnytypeText(message.creator?.title ?? Loc.Chat.newMessages, style: .chatPreviewMedium) - .foregroundColor(.Text.transparentSecondary) - .lineLimit(1) - } - } - .multilineTextAlignment(.leading) - } - - func messageWithoutAttachements(_ message: LastMessagePreview) -> some View { - Group { - if let creator = message.creator { - VStack(alignment: .leading, spacing: 2) { - Text(creator.title).anytypeFontStyle(.chatPreviewMedium) - Text(message.text).anytypeFontStyle(.chatPreviewRegular) - } - .lineLimit(1) - } else { - Text(message.text).anytypeFontStyle(.chatPreviewRegular) - .lineLimit(2) - } - } - .foregroundColor(.Text.transparentSecondary) - .anytypeLineHeightStyle(.chatPreviewRegular) - } - - @ViewBuilder - func messageWithAttachements(_ message: LastMessagePreview) -> some View { - VStack(alignment: .leading, spacing: 2) { - - if let creator = message.creator { - AnytypeText(creator.title, style: .chatPreviewMedium) - .foregroundColor(.Text.transparentSecondary) - .lineLimit(1) - } - - HStack(spacing: 2) { - ForEach(message.attachments.prefix(3)) { - IconView(icon: $0.objectIconImage).frame(width: 18, height: 18) - } - - Spacer.fixedWidth(2) - AnytypeText(message.localizedAttachmentsText, style: .chatPreviewRegular) - .foregroundColor(.Text.transparentSecondary) - .lineLimit(1) - } - } - } - - @ViewBuilder - private var lastMessageDate: some View { - if let lastMessage = spaceData.preview.lastMessage { - AnytypeText(dateFormatter.localizedDateString(for: lastMessage.createdAt, showTodayTime: true), style: .relation2Regular) - .foregroundColor(.Control.transparentSecondary) - } - } - - @ViewBuilder - private var decoration: some View { - if spaceData.preview.hasCounters { - unreadCounters - } else { - pin - } - } - - private var unreadCounters: some View { - HStack(spacing: 4) { - if spaceData.preview.mentionCounter > 0 { - MentionBadge(style: isMuted ? .muted : .highlighted) - } - if spaceData.preview.unreadCounter > 0 { - CounterView( - count: spaceData.preview.unreadCounter, - style: isMuted ? .muted : .highlighted - ) - } - } - } - - @ViewBuilder - private var pin: some View { - if spaceData.spaceView.isPinned { - Image(asset: .X18.pin) - .foregroundColor(Color.Control.transparentSecondary) - .frame(width: 18, height: 18) - } - } - - private var isMuted: Bool { - FeatureFlags.muteSpacePossibility && !spaceData.spaceView.pushNotificationMode.isUnmutedAll - } -} diff --git a/Anytype/Sources/PresentationLayer/Modules/SpaceHub/Subviews/SpaceCard/NewSpaceCardLabel.swift b/Anytype/Sources/PresentationLayer/Modules/SpaceHub/Subviews/SpaceCard/NewSpaceCardLabel.swift new file mode 100644 index 0000000000..20983907a7 --- /dev/null +++ b/Anytype/Sources/PresentationLayer/Modules/SpaceHub/Subviews/SpaceCard/NewSpaceCardLabel.swift @@ -0,0 +1,137 @@ +import SwiftUI +import AnytypeCore + +// SpaceCardLabel and SpaceCard are splitted for better SwiftUI diff. +struct NewSpaceCardLabel: View { + + let model: SpaceCardModel + @Binding var draggedSpaceViewId: String? + + @Namespace private var namespace + + var body: some View { + content + .onDragIf(model.isPinned) { + draggedSpaceViewId = model.spaceViewId + return NSItemProvider() + } preview: { + EmptyView() + } + } + + private var content: some View { + HStack(alignment: .center, spacing: 12) { + IconView(icon: model.objectIconImage) + .frame(width: 56, height: 56) + + Group { + if let message = model.lastMessage { + mainContentWithMessage(message) + } else { + mainContentWithoutMessage + } + } + .matchedGeometryEffect(id: "content", in: namespace, properties: .position, anchor: .topLeading) + } + .padding(.horizontal, 16) + .padding(.vertical, 17) + // Optimization for fast sizeThatFits + .frame(height: 98) + + .cornerRadius(20, style: .continuous) + .background(DashboardWallpaper( + mode: .spaceHub, + wallpaper: model.wallpaper, + spaceIcon: model.objectIconImage + )) + .background(Color.Background.primary) + .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) + } + + private func mainContentWithMessage(_ message: SpaceCardLastMessageModel) -> some View { + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .bottom) { + HStack(alignment: .center) { + AnytypeText(model.nameWithPlaceholder, style: .bodySemibold) + .lineLimit(1) + .foregroundColor(Color.Text.primary) + if model.isMuted { + Spacer.fixedWidth(4) + Image(asset: .X18.muted).foregroundColor(.Control.transparentSecondary) + } + } + + Spacer(minLength: 8) + + VStack(spacing: 0) { + lastMessageDate + Spacer.fixedHeight(2) + } + } + + HStack(alignment: .top) { + NewSpaceCardLastMessageView(model: message) + Spacer() + decoration + } + + Spacer(minLength: 0) + } + } + + private var mainContentWithoutMessage: some View { + VStack(alignment: .leading, spacing: 0) { + HStack { + AnytypeText(model.nameWithPlaceholder, style: .bodySemibold) + .lineLimit(1) + .foregroundColor(Color.Text.primary) + if model.isMuted { + Spacer.fixedWidth(8) + Image(asset: .X18.muted).foregroundColor(.Control.transparentSecondary) + } + Spacer() + pin + } + } + } + + @ViewBuilder + private var lastMessageDate: some View { + if let lastMessage = model.lastMessage { + AnytypeText(lastMessage.chatPreviewDate, style: .relation2Regular) + .foregroundColor(.Control.transparentSecondary) + } + } + + @ViewBuilder + private var decoration: some View { + if model.hasCounters { + unreadCounters + } else { + pin + } + } + + private var unreadCounters: some View { + HStack(spacing: 4) { + if model.mentionCounter > 0 { + MentionBadge(style: model.isMuted ? .muted : .highlighted) + } + if model.unreadCounter > 0 { + CounterView( + count: model.unreadCounter, + style: model.isMuted ? .muted : .highlighted + ) + } + } + } + + @ViewBuilder + private var pin: some View { + if model.isPinned { + Image(asset: .X18.pin) + .foregroundColor(Color.Control.transparentSecondary) + .frame(width: 18, height: 18) + } + } +} diff --git a/Anytype/Sources/PresentationLayer/Modules/SpaceHub/Subviews/SpaceCard/NewSpaceCardLastMessageView.swift b/Anytype/Sources/PresentationLayer/Modules/SpaceHub/Subviews/SpaceCard/NewSpaceCardLastMessageView.swift new file mode 100644 index 0000000000..5f79e6c033 --- /dev/null +++ b/Anytype/Sources/PresentationLayer/Modules/SpaceHub/Subviews/SpaceCard/NewSpaceCardLastMessageView.swift @@ -0,0 +1,60 @@ +import SwiftUI + +struct NewSpaceCardLastMessageView: View { + + let model: SpaceCardLastMessageModel + + var body: some View { + Group { + if model.attachments.isNotEmpty { + messageWithAttachements + } else if model.text.isNotEmpty { + messageWithoutAttachements + } else { + AnytypeText(model.creatorTitle ?? Loc.Chat.newMessages, style: .chatPreviewMedium) + .foregroundColor(.Text.transparentSecondary) + .lineLimit(1) + } + } + .multilineTextAlignment(.leading) + } + + private var messageWithoutAttachements: some View { + Group { + if let creatorTitle = model.creatorTitle { + VStack(alignment: .leading, spacing: 2) { + Text(creatorTitle).anytypeFontStyle(.chatPreviewMedium) + Text(model.text).anytypeFontStyle(.chatPreviewRegular) + } + .lineLimit(1) + } else { + Text(model.text).anytypeFontStyle(.chatPreviewRegular) + .lineLimit(2) + } + } + .foregroundColor(.Text.transparentSecondary) + .anytypeLineHeightStyle(.chatPreviewRegular) + } + + private var messageWithAttachements: some View { + VStack(alignment: .leading, spacing: 2) { + + if let creatorTitle = model.creatorTitle { + AnytypeText(creatorTitle, style: .chatPreviewMedium) + .foregroundColor(.Text.transparentSecondary) + .lineLimit(1) + } + + HStack(spacing: 2) { + ForEach(model.attachments) { + IconView(icon: $0.icon).frame(width: 18, height: 18) + } + + Spacer.fixedWidth(2) + AnytypeText(model.localizedAttachmentsText, style: .chatPreviewRegular) + .foregroundColor(.Text.transparentSecondary) + .lineLimit(1) + } + } + } +} diff --git a/Anytype/Sources/PresentationLayer/Modules/SpaceHub/Subviews/SpaceCard.swift b/Anytype/Sources/PresentationLayer/Modules/SpaceHub/Subviews/SpaceCard/SpaceCard.swift similarity index 67% rename from Anytype/Sources/PresentationLayer/Modules/SpaceHub/Subviews/SpaceCard.swift rename to Anytype/Sources/PresentationLayer/Modules/SpaceHub/Subviews/SpaceCard/SpaceCard.swift index 2e9c91c5fe..98f82347d4 100644 --- a/Anytype/Sources/PresentationLayer/Modules/SpaceHub/Subviews/SpaceCard.swift +++ b/Anytype/Sources/PresentationLayer/Modules/SpaceHub/Subviews/SpaceCard/SpaceCard.swift @@ -1,11 +1,10 @@ import SwiftUI import AnytypeCore -struct SpaceCard: View, @preconcurrency Equatable { - - let spaceData: ParticipantSpaceViewDataWithPreview - let wallpaper: SpaceWallpaperType - @Binding var draggedSpace: ParticipantSpaceViewDataWithPreview? +struct SpaceCard: View { + + let model: SpaceCardModel + @Binding var draggedSpaceViewId: String? let onTap: () -> Void let onTapCopy: () -> Void let onTapMute: () -> Void @@ -13,22 +12,23 @@ struct SpaceCard: View, @preconcurrency Equatable { let onTapUnpin: () async throws -> Void let onTapSettings: () -> Void let onTapDelete: () -> Void + + @State private var vaultBackToRootsToggle = FeatureFlags.vaultBackToRoots + @State private var muteSpacePossibilityToggle = FeatureFlags.muteSpacePossibility var body: some View { Button { onTap() } label: { - if !FeatureFlags.vaultBackToRoots { + if !vaultBackToRootsToggle { SpaceCardLabel( - spaceData: spaceData, - wallpaper: wallpaper, - draggedSpace: $draggedSpace + model: model, + draggedSpaceViewId: $draggedSpaceViewId ) } else { NewSpaceCardLabel( - spaceData: spaceData, - wallpaper: wallpaper, - draggedSpace: $draggedSpace + model: model, + draggedSpaceViewId: $draggedSpaceViewId ) } } @@ -38,22 +38,22 @@ struct SpaceCard: View, @preconcurrency Equatable { @ViewBuilder private var menuItems: some View { - if spaceData.spaceView.isLoading { + if model.isLoading { copyButton Divider() } - if spaceData.spaceView.isPinned { + if model.isPinned { unpinButton } else { pinButton } - if FeatureFlags.muteSpacePossibility, spaceData.spaceView.isShared { + if muteSpacePossibilityToggle, model.isShared { muteButton } - if spaceData.spaceView.isLoading { + if model.isLoading { deleteButton } else { settingsButton @@ -73,9 +73,9 @@ struct SpaceCard: View, @preconcurrency Equatable { onTapMute() } label: { HStack { - Text(spaceData.spaceView.pushNotificationMode.isUnmutedAll ? Loc.mute : Loc.unmute) + Text(model.allNotificationsUnmuted ? Loc.mute : Loc.unmute) Spacer() - Image(systemName: spaceData.spaceView.pushNotificationMode.isUnmutedAll ? "bell.slash" : "bell") + Image(systemName: model.allNotificationsUnmuted ? "bell.slash" : "bell") } } } @@ -122,10 +122,4 @@ struct SpaceCard: View, @preconcurrency Equatable { .tint(.red) } } - - static func == (lhs: Self, rhs: Self) -> Bool { - lhs.spaceData == rhs.spaceData - && lhs.wallpaper == rhs.wallpaper - && lhs.draggedSpace == rhs.draggedSpace - } } diff --git a/Anytype/Sources/PresentationLayer/Modules/SpaceHub/Subviews/SpaceCard/SpaceCardLabel.swift b/Anytype/Sources/PresentationLayer/Modules/SpaceHub/Subviews/SpaceCard/SpaceCardLabel.swift new file mode 100644 index 0000000000..193747a17d --- /dev/null +++ b/Anytype/Sources/PresentationLayer/Modules/SpaceHub/Subviews/SpaceCard/SpaceCardLabel.swift @@ -0,0 +1,99 @@ +import SwiftUI +import AnytypeCore + +// SpaceCardLabel and SpaceCard are splitted for better SwiftUI diff. +struct SpaceCardLabel: View { + + let model: SpaceCardModel + @Binding var draggedSpaceViewId: String? + + @Namespace private var namespace + + var body: some View { + HStack(alignment: .center, spacing: 12) { + IconView(icon: model.objectIconImage) + .frame(width: 56, height: 56) + VStack(alignment: .leading, spacing: 0) { + HStack { + Text(model.nameWithPlaceholder) + .anytypeFontStyle(.bodySemibold) + .lineLimit(1) + .foregroundStyle(Color.Text.primary) + if model.isMuted { + Spacer.fixedWidth(8) + Image(asset: .X18.muted).foregroundColor(.Control.secondary) + } + Spacer(minLength: 8) + createdDate + } + HStack { + info + Spacer() + unreadCounters + pin + } + Spacer(minLength: 1) + } + .matchedGeometryEffect(id: "content", in: namespace, properties: .position, anchor: .topLeading) + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + // Optimization for fast sizeThatFits + .frame(height: 80) + .background(Color.Background.primary) + .contentShape([.dragPreview, .contextMenuPreview], RoundedRectangle(cornerRadius: 20, style: .continuous)) + .onDragIf(model.isPinned) { + draggedSpaceViewId = model.spaceViewId + return NSItemProvider() + } preview: { + EmptyView() + } + } + + private var info: some View { + Group { + if let lastMessage = model.lastMessage { + SpaceCardLastMessageView(model: lastMessage) + } else { + Text(model.uxTypeName) + .anytypeStyle(.uxTitle2Regular) + .lineLimit(1) + } + } + .foregroundStyle(Color.Text.secondary) + .multilineTextAlignment(.leading) + } + + + @ViewBuilder + private var createdDate: some View { + if let lastMessage = model.lastMessage { + Text(lastMessage.historyDate) + .anytypeStyle(.relation2Regular) + .foregroundStyle(Color.Control.transparentSecondary) + } + } + + private var unreadCounters: some View { + HStack(spacing: 4) { + if model.mentionCounter > 0 { + MentionBadge(style: model.isMuted ? .muted : .highlighted) + } + if model.unreadCounter > 0 { + CounterView( + count: model.unreadCounter, + style: model.isMuted ? .muted : .highlighted + ) + } + } + } + + @ViewBuilder + private var pin: some View { + if !model.hasCounters && model.isPinned { + Image(asset: .X18.pin) + .foregroundStyle(Color.Control.secondary) + .frame(width: 18, height: 18) + } + } +} diff --git a/Anytype/Sources/PresentationLayer/Modules/SpaceHub/Subviews/SpaceCard/SpaceCardLastMessageModel.swift b/Anytype/Sources/PresentationLayer/Modules/SpaceHub/Subviews/SpaceCard/SpaceCardLastMessageModel.swift new file mode 100644 index 0000000000..e3760f85c3 --- /dev/null +++ b/Anytype/Sources/PresentationLayer/Modules/SpaceHub/Subviews/SpaceCard/SpaceCardLastMessageModel.swift @@ -0,0 +1,17 @@ +import Foundation + +struct SpaceCardLastMessageModel: Equatable { + let creatorTitle: String? + let text: String + let attachments: [Attachment] + let localizedAttachmentsText: String + let historyDate: String + let chatPreviewDate: String +} + +extension SpaceCardLastMessageModel { + struct Attachment: Equatable, Identifiable { + let id: String + let icon: Icon + } +} diff --git a/Anytype/Sources/PresentationLayer/Modules/SpaceHub/Subviews/SpaceCard/SpaceCardLastMessageView.swift b/Anytype/Sources/PresentationLayer/Modules/SpaceHub/Subviews/SpaceCard/SpaceCardLastMessageView.swift new file mode 100644 index 0000000000..53564384fb --- /dev/null +++ b/Anytype/Sources/PresentationLayer/Modules/SpaceHub/Subviews/SpaceCard/SpaceCardLastMessageView.swift @@ -0,0 +1,47 @@ +import SwiftUI + +struct SpaceCardLastMessageView: View { + + let model: SpaceCardLastMessageModel + + var body: some View { + if model.text.isNotEmpty { + // Do not show attachements due to SwiftUI limitations: + // Can not fit attachements in between two lines of text with proper multiline behaviour + messageWithoutAttachements + } else if model.attachments.isNotEmpty { + // Show attachements and 1 line of text + messageWithAttachements + } else { + Text(model.creatorTitle ?? Loc.Chat.newMessages) + .anytypeStyle(.uxTitle2Medium).lineLimit(1) + } + } + + private var messageWithoutAttachements: some View { + Group { + if let creatorTitle = model.creatorTitle { + Text(creatorTitle + ": ").anytypeFontStyle(.uxTitle2Medium) + + Text(model.text).anytypeFontStyle(.uxTitle2Regular) + } else { + Text(model.text).anytypeFontStyle(.uxTitle2Regular) + } + }.lineLimit(2).anytypeLineHeightStyle(.uxTitle2Regular) + } + + private var messageWithAttachements: some View { + HStack(spacing: 2) { + if let creatorTitle = model.creatorTitle { + Text(creatorTitle + ":").anytypeStyle(.uxTitle2Medium).lineLimit(1) + Spacer.fixedWidth(4) + } + + ForEach(model.attachments) { + IconView(icon: $0.icon).frame(width: 18, height: 18) + } + + Spacer.fixedWidth(4) + Text(model.localizedAttachmentsText).anytypeStyle(.uxTitle2Regular).lineLimit(1) + } + } +} diff --git a/Anytype/Sources/PresentationLayer/Modules/SpaceHub/Subviews/SpaceCard/SpaceCardModel.swift b/Anytype/Sources/PresentationLayer/Modules/SpaceHub/Subviews/SpaceCard/SpaceCardModel.swift new file mode 100644 index 0000000000..fe30045972 --- /dev/null +++ b/Anytype/Sources/PresentationLayer/Modules/SpaceHub/Subviews/SpaceCard/SpaceCardModel.swift @@ -0,0 +1,24 @@ +import Services +import AnytypeCore + +struct SpaceCardModel: Equatable, Identifiable { + let spaceViewId: String + let targetSpaceId: String + let objectIconImage: Icon + let nameWithPlaceholder: String + let isPinned: Bool + let isLoading: Bool + let isShared: Bool + let isMuted: Bool + let uxTypeName: String + let allNotificationsUnmuted: Bool + + let lastMessage: SpaceCardLastMessageModel? + let unreadCounter: Int + let mentionCounter: Int + let hasCounters: Bool + + let wallpaper: SpaceWallpaperType + + var id: String { spaceViewId } +} diff --git a/Anytype/Sources/PresentationLayer/Modules/SpaceHub/Subviews/SpaceCard/SpaceCardModelBuilder.swift b/Anytype/Sources/PresentationLayer/Modules/SpaceHub/Subviews/SpaceCard/SpaceCardModelBuilder.swift new file mode 100644 index 0000000000..4a3405ad5a --- /dev/null +++ b/Anytype/Sources/PresentationLayer/Modules/SpaceHub/Subviews/SpaceCard/SpaceCardModelBuilder.swift @@ -0,0 +1,77 @@ +import Services +import AnytypeCore +import Factory + +protocol SpaceCardModelBuilderProtocol: AnyObject, Sendable { + func build( + from spaces: [ParticipantSpaceViewDataWithPreview], + wallpapers: [String: SpaceWallpaperType] + ) async -> [SpaceCardModel] +} + +final class SpaceCardModelBuilder: SpaceCardModelBuilderProtocol, Sendable { + + private let historyDateFormatter = HistoryDateFormatter() + private let chatPreviewDateFormatter = ChatPreviewDateFormatter() + + func build( + from spaces: [ParticipantSpaceViewDataWithPreview], + wallpapers: [String: SpaceWallpaperType] + ) async -> [SpaceCardModel] { + await Task.detached { + spaces.map { spaceData in + self.buildModel(from: spaceData, wallpapers: wallpapers) + } + }.value + } + + private func buildModel( + from spaceData: ParticipantSpaceViewDataWithPreview, + wallpapers: [String: SpaceWallpaperType] + ) -> SpaceCardModel { + let spaceView = spaceData.spaceView + let preview = spaceData.preview + + let lastMessage = preview.lastMessage.map { lastMessagePreview in + let attachments = lastMessagePreview.attachments.prefix(3).map { objectDetails in + SpaceCardLastMessageModel.Attachment( + id: objectDetails.id, + icon: objectDetails.objectIconImage + ) + } + + return SpaceCardLastMessageModel( + creatorTitle: lastMessagePreview.creator?.title, + text: lastMessagePreview.text, + attachments: Array(attachments), + localizedAttachmentsText: lastMessagePreview.localizedAttachmentsText, + historyDate: historyDateFormatter.localizedDateString(for: lastMessagePreview.createdAt, showTodayTime: true), + chatPreviewDate: chatPreviewDateFormatter.localizedDateString(for: lastMessagePreview.createdAt, showTodayTime: true) + ) + } + + return SpaceCardModel( + spaceViewId: spaceView.id, + targetSpaceId: spaceView.targetSpaceId, + objectIconImage: spaceView.objectIconImage, + nameWithPlaceholder: spaceView.name.withPlaceholder, + isPinned: spaceView.isPinned, + isLoading: spaceView.isLoading, + isShared: spaceView.isShared, + isMuted: FeatureFlags.muteSpacePossibility && !spaceView.pushNotificationMode.isUnmutedAll, + uxTypeName: spaceView.uxType.name, + allNotificationsUnmuted: spaceView.pushNotificationMode.isUnmutedAll, + lastMessage: lastMessage, + unreadCounter: preview.unreadCounter, + mentionCounter: preview.mentionCounter, + hasCounters: preview.hasCounters, + wallpaper: wallpapers[spaceView.targetSpaceId] ?? .default + ) + } +} + +extension Container { + var spaceCardModelBuilder: Factory< any SpaceCardModelBuilderProtocol> { + self { SpaceCardModelBuilder() }.shared + } +} diff --git a/Anytype/Sources/PresentationLayer/Modules/SpaceHub/Subviews/SpaceCardLabel.swift b/Anytype/Sources/PresentationLayer/Modules/SpaceHub/Subviews/SpaceCardLabel.swift deleted file mode 100644 index fa0f4c83a0..0000000000 --- a/Anytype/Sources/PresentationLayer/Modules/SpaceHub/Subviews/SpaceCardLabel.swift +++ /dev/null @@ -1,155 +0,0 @@ -import SwiftUI -import AnytypeCore - -// SpaceCardLabel and SpaceCard are splitted for better SwiftUI diff. -struct SpaceCardLabel: View { - - let spaceData: ParticipantSpaceViewDataWithPreview - let wallpaper: SpaceWallpaperType - private let dateFormatter = HistoryDateFormatter() - @Binding var draggedSpace: ParticipantSpaceViewDataWithPreview? - - @Namespace private var namespace - - var body: some View { - HStack(alignment: .center, spacing: 12) { - IconView(icon: spaceData.spaceView.objectIconImage) - .frame(width: 56, height: 56) - VStack(alignment: .leading, spacing: 0) { - HStack { - Text(spaceData.spaceView.name.withPlaceholder) - .anytypeFontStyle(.bodySemibold) - .lineLimit(1) - .foregroundStyle(Color.Text.primary) - if isMuted { - Spacer.fixedWidth(8) - Image(asset: .X18.muted).foregroundColor(.Control.secondary) - } - Spacer(minLength: 8) - createdDate - } - HStack { - info - Spacer() - unreadCounters - pin - } - Spacer(minLength: 1) - } - // Fixing the animation when the cell is moved and updated inside - // Optimization - create a data model for SpaceCard and map to in in SpaceHubViewModel on background thread - .id(spaceData.hashValue) - .matchedGeometryEffect(id: "content", in: namespace, properties: .position, anchor: .topLeading) - } - .padding(.horizontal, 16) - .padding(.vertical, 8) - // Optimization for fast sizeThatFits - .frame(height: 80) - .background(Color.Background.primary) - .contentShape([.dragPreview, .contextMenuPreview], RoundedRectangle(cornerRadius: 20, style: .continuous)) - - .if(spaceData.spaceView.isPinned) { - $0.onDrag { - draggedSpace = spaceData - return NSItemProvider() - } preview: { - EmptyView() - } - } - } - - private var info: some View { - Group { - if let lastMessage = spaceData.preview.lastMessage { - lastMessagePreview(lastMessage) - } else { - Text(spaceData.spaceView.uxType.name) - .anytypeStyle(.uxTitle2Regular) - .lineLimit(1) - } - } - .foregroundStyle(Color.Text.secondary) - .multilineTextAlignment(.leading) - } - - @ViewBuilder - func lastMessagePreview(_ message: LastMessagePreview) -> some View { - Group { - if message.text.isNotEmpty { - // Do not show attachements due to SwiftUI limitations: - // Can not fit attachements in between two lines of text with proper multiline behaviour - messageWithoutAttachements(message) - } else if message.attachments.isNotEmpty { - // Show attachements and 1 line of text - messageWithAttachements(message) - } else { - Text(message.creator?.title ?? Loc.Chat.newMessages) - .anytypeStyle(.uxTitle2Medium).lineLimit(1) - } - } - } - - func messageWithoutAttachements(_ message: LastMessagePreview) -> some View { - Group { - if let creator = message.creator { - Text(creator.title + ": ").anytypeFontStyle(.uxTitle2Medium) + - Text(message.text).anytypeFontStyle(.uxTitle2Regular) - } else { - Text(message.text).anytypeFontStyle(.uxTitle2Regular) - } - }.lineLimit(2).anytypeLineHeightStyle(.uxTitle2Regular) - } - - @ViewBuilder - func messageWithAttachements(_ message: LastMessagePreview) -> some View { - HStack(spacing: 2) { - if let creator = message.creator { - Text(creator.title + ":").anytypeStyle(.uxTitle2Medium).lineLimit(1) - Spacer.fixedWidth(4) - } - - ForEach(message.attachments.prefix(3)) { - IconView(icon: $0.objectIconImage).frame(width: 18, height: 18) - } - - Spacer.fixedWidth(4) - Text(message.localizedAttachmentsText).anytypeStyle(.uxTitle2Regular).lineLimit(1) - } - } - - @ViewBuilder - private var createdDate: some View { - if let lastMessage = spaceData.preview.lastMessage { - Text(dateFormatter.localizedDateString(for: lastMessage.createdAt, showTodayTime: true)) - .anytypeStyle(.relation2Regular) - .foregroundStyle(Color.Control.transparentSecondary) - } - } - - private var unreadCounters: some View { - HStack(spacing: 4) { - if spaceData.preview.mentionCounter > 0 { - MentionBadge(style: isMuted ? .muted : .highlighted) - } - if spaceData.preview.unreadCounter > 0 { - CounterView( - count: spaceData.preview.unreadCounter, - style: isMuted ? .muted : .highlighted - ) - } - } - } - - @ViewBuilder - private var pin: some View { - if !spaceData.preview.hasCounters && spaceData.spaceView.isPinned { - Image(asset: .X18.pin) - .foregroundStyle(Color.Control.secondary) - .frame(width: 18, height: 18) - } - } - - private var isMuted: Bool { - FeatureFlags.muteSpacePossibility && !spaceData.spaceView.pushNotificationMode.isUnmutedAll - } -} diff --git a/Anytype/Sources/PresentationLayer/Modules/SpaceHub/Subviews/SpaceHubList.swift b/Anytype/Sources/PresentationLayer/Modules/SpaceHub/Subviews/SpaceHubList.swift new file mode 100644 index 0000000000..f42f52c264 --- /dev/null +++ b/Anytype/Sources/PresentationLayer/Modules/SpaceHub/Subviews/SpaceHubList.swift @@ -0,0 +1,84 @@ +import SwiftUI +import AnytypeCore + +// Is part of main view SpaceHubView. Related from SpaceHubViewModel +struct SpaceHubList: View { + + @Bindable var model: SpaceHubViewModel + + @State private var draggedSpaceViewId: String? + @State private var draggedInitialIndex: Int? + @State private var vaultBackToRootsToggle = FeatureFlags.vaultBackToRoots + + var body: some View { + if model.filteredSpaces.isEmpty && model.searchText.isEmpty { + emptyStateView + } else if model.filteredSpaces.isNotEmpty { + scrollView + } else { + SpaceHubSearchEmptySpaceView() + } + } + + private var scrollView: some View { + ScrollView { + VStack(spacing: vaultBackToRootsToggle ? 8 : 0) { + HomeUpdateSubmoduleView().padding(8) + + ForEach(model.filteredSpaces) { + spaceCard($0) + } + + Spacer.fixedHeight(40) + } + } + .animation(.default, value: model.filteredSpaces) + } + + private var emptyStateView: some View { + SpaceHubEmptyStateView { + model.onTapCreateSpace() + } + } + + @ViewBuilder + private func spaceCard(_ cardModel: SpaceCardModel) -> some View { + SpaceCard( + model: cardModel, + draggedSpaceViewId: $draggedSpaceViewId, + onTap: { + model.onSpaceTap(spaceId: cardModel.targetSpaceId) + }, + onTapCopy: { + model.copySpaceInfo(spaceViewId: cardModel.spaceViewId) + }, + onTapMute: { + model.muteSpace(spaceViewId: cardModel.spaceViewId) + }, + onTapPin: { + try await model.pin(spaceViewId: cardModel.spaceViewId) + }, + onTapUnpin: { + try await model.unpin(spaceViewId: cardModel.spaceViewId) + }, + onTapSettings: { + model.openSpaceSettings(spaceId: cardModel.targetSpaceId) + }, + onTapDelete: { + model.onDeleteSpace(spaceId: cardModel.targetSpaceId) + } + ) + .padding(.horizontal, vaultBackToRootsToggle ? 16 : 0) + .onDropIf( + cardModel.isPinned, + of: [.text], + delegate: SpaceHubDropDelegate( + destinationSpaceViewId: cardModel.spaceViewId, + allSpaces: $model.spaces, + draggedSpaceViewId: $draggedSpaceViewId, + initialIndex: $draggedInitialIndex + ) + ) + .id(cardModel.id) + } +} diff --git a/Modules/DesignKit/Sources/DesignKit/SystemExtensions/View+DragDrop.swift b/Modules/DesignKit/Sources/DesignKit/SystemExtensions/View+DragDrop.swift new file mode 100644 index 0000000000..247c63e2f2 --- /dev/null +++ b/Modules/DesignKit/Sources/DesignKit/SystemExtensions/View+DragDrop.swift @@ -0,0 +1,91 @@ +import SwiftUI +import UniformTypeIdentifiers + +public extension View { + + func onDragIf( + _ condition: Bool, + data: @escaping () -> NSItemProvider + ) -> some View { + modifier(ConditionalDragModifier(condition: condition, data: data)) + } + + func onDragIf( + _ condition: Bool, + data: @escaping () -> NSItemProvider, + @ViewBuilder preview: @escaping () -> V + ) -> some View where V: View { + modifier(ConditionalDragWithPreviewModifier(condition: condition, data: data, preview: preview)) + } + + func onDropIf( + _ condition: Bool, + of supportedContentTypes: [UTType], + delegate: any DropDelegate + ) -> some View { + modifier( + ConditionalDropModifier( + condition: condition, + supportedContentTypes: supportedContentTypes, + delegate: delegate + ) + ) + } +} + +private struct ConditionalDragModifier: ViewModifier { + let condition: Bool + let data: () -> NSItemProvider + + @Namespace private var namespace + + func body(content: Content) -> some View { + if condition { + content + .onDrag(data) + .matchedGeometryEffect(id: "content", in: namespace) + } else { + content + .matchedGeometryEffect(id: "content", in: namespace) + } + } +} + +private struct ConditionalDragWithPreviewModifier: ViewModifier { + + let condition: Bool + let data: () -> NSItemProvider + let preview: () -> Preview + + @Namespace private var namespace + + func body(content: Content) -> some View { + if condition { + content + .onDrag(data, preview: preview) + .matchedGeometryEffect(id: "content", in: namespace) + } else { + content + .matchedGeometryEffect(id: "content", in: namespace) + } + } +} + +private struct ConditionalDropModifier: ViewModifier { + let condition: Bool + let supportedContentTypes: [UTType] + let delegate: any DropDelegate + + @Namespace private var namespace + + func body(content: Content) -> some View { + if condition { + content + .onDrop(of: supportedContentTypes, delegate: delegate) + .matchedGeometryEffect(id: "content", in: namespace) + } else { + content + .matchedGeometryEffect(id: "content", in: namespace) + } + } +}